diff --git a/application/Espo/Core/Api/Auth.php b/application/Espo/Core/Api/Auth.php index 939b5b2ddc..2776eaff52 100644 --- a/application/Espo/Core/Api/Auth.php +++ b/application/Espo/Core/Api/Auth.php @@ -67,7 +67,7 @@ class Auth $this->authRequired = $authRequired; } - public function createForEntryPoint(Authentication $authentication, bool $authRequired = true) + public static function createForEntryPoint(Authentication $authentication, bool $authRequired = true) { $instance = new Auth($authentication, $authRequired); $instance->isEntryPoint = true; diff --git a/application/Espo/Core/Cleanup/Reminders.php b/application/Espo/Core/Cleanup/Reminders.php index 358c0a0bd7..5eeccdbc84 100644 --- a/application/Espo/Core/Cleanup/Reminders.php +++ b/application/Espo/Core/Cleanup/Reminders.php @@ -54,15 +54,14 @@ class Reminders $dt = new \DateTime(); $dt->modify($period); - $pdo = $this->entityManager->getPDO(); + $delete = $this->entityManager->getQueryBuilder() + ->delete() + ->from('Reminder') + ->where([ + 'remindAt<' => $dt->format('Y-m-d'), + ]) + ->build(); - $sql = $this->entityManager->getQuery()->createDeleteQuery('Reminder', [ - 'whereClause' => [ - 'remindAt<' => $dt->format('Y-m-d') - ], - ]); - - $sth = $pdo->prepare($sql); - $sth->execute(); + $this->entityManager->getQueryExecutor()->run($delete); } } diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/SumRelatedType.php b/application/Espo/Core/Formula/Functions/EntityGroup/SumRelatedType.php index 7673c51feb..167a6211c5 100644 --- a/application/Espo/Core/Formula/Functions/EntityGroup/SumRelatedType.php +++ b/application/Espo/Core/Formula/Functions/EntityGroup/SumRelatedType.php @@ -134,7 +134,7 @@ class SumRelatedType extends \Espo\Core\Formula\Functions\Base implements $entityManager->getRepository($foreignEntityType)->handleSelectParams($selectParams); - $sql = $entityManager->getQuery()->createSelectQuery($foreignEntityType, $selectParams); + $sql = $entityManager->getQueryComposer()->createSelectQuery($foreignEntityType, $selectParams); $pdo = $entityManager->getPDO(); $sth = $pdo->prepare($sql); diff --git a/application/Espo/Core/ORM/Repository/HookMediator.php b/application/Espo/Core/ORM/Repository/HookMediator.php new file mode 100644 index 0000000000..53b6322ba8 --- /dev/null +++ b/application/Espo/Core/ORM/Repository/HookMediator.php @@ -0,0 +1,98 @@ +hookManager = $hookManager; + } + + public function afterRelate(Entity $entity, string $relationName, Entity $foreignEntity, ?array $columnData, array $options) + { + if (!empty($options['skipHooks'])) { + return; + } + + $hookData = [ + 'relationName' => $relationName, + 'relationData' => $columnData, + 'foreignEntity' => $foreignEntity, + 'foreignId' => $foreignEntity->id, + ]; + + $this->hookManager->process( + $entity->getEntityType(), 'afterRelate', $entity, $options, $hookData + ); + } + public function afterUnrelate(Entity $entity, string $relationName, Entity $foreignEntity, array $options) + { + if (!empty($options['skipHooks'])) { + return; + } + + $hookData = [ + 'relationName' => $relationName, + 'foreignEntity' => $foreignEntity, + 'foreignId' => $foreignEntity->id, + ]; + + $this->hookManager->process( + $entity->getEntityType(), 'afterUnrelate', $entity, $options, $hookData + ); + } + + public function afterMassRelate(Entity $entity, string $relationName, Select $query, array $options) + { + if (!empty($options['skipHooks'])) { + return; + } + + $hookData = [ + 'relationName' => $relationName, + 'query' => $query, + ]; + + $this->hookManager->process( + $entity->getEntityType(), 'afterMassRelate', $entity, $options, $hookData + ); + } +} diff --git a/application/Espo/Core/ORM/RepositoryFactory.php b/application/Espo/Core/ORM/RepositoryFactory.php index f00d6fae9d..34f69ada30 100644 --- a/application/Espo/Core/ORM/RepositoryFactory.php +++ b/application/Espo/Core/ORM/RepositoryFactory.php @@ -35,7 +35,10 @@ use Espo\Core\{ Repositories\Database as DatabaseRepository, }; -use Espo\ORM\RepositoryFactory as RepositoryFactoryInterface; +use Espo\ORM\{ + Repository\RepositoryFactory as RepositoryFactoryInterface, + Repository\Repository, +}; class RepositoryFactory implements RepositoryFactoryInterface { @@ -58,7 +61,7 @@ class RepositoryFactory implements RepositoryFactoryInterface return $this->classFinder->find('Repositories', $name); } - public function create(string $name) : object + public function create(string $name) : Repository { $className = $this->getClassName($name); diff --git a/application/Espo/Core/Repositories/CategoryTree.php b/application/Espo/Core/Repositories/CategoryTree.php index 50bb4f4c65..08f4fad067 100644 --- a/application/Espo/Core/Repositories/CategoryTree.php +++ b/application/Espo/Core/Repositories/CategoryTree.php @@ -38,7 +38,7 @@ class CategoryTree extends Database parent::afterSave($entity, $options); $pdo = $this->getEntityManager()->getPDO(); - $query = $this->getEntityManager()->getQuery(); + $query = $this->getEntityManager()->getQueryComposer(); $parentId = $entity->get('parentId'); $pathsTableName = $query->toDb($query->sanitize($entity->getEntityType()) . 'Path'); @@ -91,7 +91,7 @@ class CategoryTree extends Database parent::afterRemove($entity, $options); $pdo = $this->getEntityManager()->getPDO(); - $query = $this->getEntityManager()->getQuery(); + $query = $this->getEntityManager()->getQueryComposer(); $pathsTableName = $query->toDb($query->sanitize($entity->getEntityType()) . 'Path'); diff --git a/application/Espo/Core/Repositories/Database.php b/application/Espo/Core/Repositories/Database.php index 8f4782d736..3d6d4ae40d 100644 --- a/application/Espo/Core/Repositories/Database.php +++ b/application/Espo/Core/Repositories/Database.php @@ -30,13 +30,14 @@ namespace Espo\Core\Repositories; use Espo\ORM\{ - Repositories\RDB, + Repository\RDBRepository, Entity, }; use Espo\Core\ORM\{ EntityManager, EntityFactory, + Repository\HookMediator, }; use Espo\Core\{ @@ -46,7 +47,7 @@ use Espo\Core\{ ApplicationState, }; -class Database extends RDB +class Database extends RDBRepository { protected $hooksDisabled = false; @@ -72,7 +73,13 @@ class Database extends RDB $this->hookManager = $hookManager; $this->applicationState = $applicationState; - parent::__construct($entityType, $entityManager, $entityFactory, $metadata); + $hookMediator = null; + + if (!$this->hooksDisabled) { + $hookMediator = new HookMediator($hookManager); + } + + parent::__construct($entityType, $entityManager, $entityFactory, $hookMediator); } protected function getMetadata() @@ -178,8 +185,7 @@ class Database extends RDB public function remove(Entity $entity, array $options = []) { - $result = parent::remove($entity, $options); - return $result; + return parent::remove($entity, $options); } protected function afterRelate(Entity $entity, $relationName, $foreign, $data = null, array $options = []) @@ -198,15 +204,7 @@ class Database extends RDB } if ($foreign instanceof Entity) { - $hookData = [ - 'relationName' => $relationName, - 'relationData' => $data, - 'foreignEntity' => $foreign, - 'foreignId' => $foreign->id, - ]; - $this->hookManager->process( - $this->entityType, 'afterRelate', $entity, $options, $hookData - ); + $this->hookMediator->afterRelate($entity, $relationName, $foreign, $data, $options); } } } @@ -227,14 +225,7 @@ class Database extends RDB } if ($foreign instanceof Entity) { - $hookData = [ - 'relationName' => $relationName, - 'foreignEntity' => $foreign, - 'foreignId' => $foreign->id, - ]; - $this->hookManager->process( - $this->entityType, 'afterUnrelate', $entity, $options, $hookData - ); + $this->hookMediator->afterUnrelate($entity, $relationName, $foreign, $options); } } } diff --git a/application/Espo/Core/Repositories/Event.php b/application/Espo/Core/Repositories/Event.php index aa68561b50..f4664d48a2 100644 --- a/application/Espo/Core/Repositories/Event.php +++ b/application/Espo/Core/Repositories/Event.php @@ -34,7 +34,7 @@ use Espo\Core\Utils\Util; use Espo\Core\Di; -class Event extends \Espo\Core\Repositories\Database implements +class Event extends Database implements Di\DateTimeAware, Di\ConfigAware { @@ -125,16 +125,16 @@ class Event extends \Espo\Core\Repositories\Database implements { parent::afterRemove($entity, $options); - $pdo = $this->getEntityManager()->getPDO(); - - $sql = $this->getEntityManager()->getQuery()->createDeleteQuery('Reminder', [ - 'whereClause' => [ + $delete = $this->getEntityManager()->getQueryBuilder() + ->delete() + ->from('Reminder') + ->where([ 'entityId' => $entity->id, 'entityType' => $entity->getEntityType(), - ], - ]); + ]) + ->build(); - $pdo->query($sql); + $this->getEntityManager()->getQueryExecutor()->run($delete); } protected function afterSave(Entity $entity, array $options = []) diff --git a/application/Espo/Core/Select/SelectManager.php b/application/Espo/Core/Select/SelectManager.php index a395b9d4d5..9063ed6224 100644 --- a/application/Espo/Core/Select/SelectManager.php +++ b/application/Espo/Core/Select/SelectManager.php @@ -44,7 +44,7 @@ use Espo\Core\{ ORM\EntityManager, }; -use Espo\ORM\DB\Query\BaseQuery as Query; +use Espo\ORM\QueryComposer\BaseQueryComposer as QueryComposer; use Espo\ORM\Entity; @@ -373,19 +373,23 @@ class SelectManager $key = $midKeys[1]; $part[$link . 'Filter' . 'Middle.' . $key] = $idsValue; } + } else if ($relationType == 'hasMany') { $alias = $link . 'Filter'; $this->addLeftJoin([$link, $alias], $result); $part[$alias . '.id'] = $idsValue; + } else if ($relationType == 'belongsTo') { $key = $seed->getRelationParam($link, 'key'); if (!empty($key)) { $part[$key] = $idsValue; } + } else if ($relationType == 'hasOne') { $this->addJoin([$link, $link . 'Filter'], $result); $part[$link . 'Filter' . '.id'] = $idsValue; + } else { return; } @@ -403,7 +407,7 @@ class SelectManager $idsValue = $idsValue[0]; } - $query = $this->getEntityManager()->getQuery(); + $query = $this->getEntityManager()->getQueryComposer(); $seed = $this->getSeed(); @@ -441,7 +445,7 @@ class SelectManager { $relDefs = $this->getSeed()->getRelations(); - $query = $this->getEntityManager()->getQuery(); + $query = $this->getEntityManager()->getQueryComposer(); $tableName = $query->toDb($this->getSeed()->getEntityType()); @@ -542,6 +546,9 @@ class SelectManager if (empty($result)) { $result = []; } + if (empty($result['from'])) { + $result['from'] = $this->entityType; + } if (empty($result['joins'])) { $result['joins'] = []; } @@ -980,7 +987,7 @@ class SelectManager } if ($attribute && $checkWherePermission) { - $argumentList = Query::getAllAttributesFromComplexExpression($attribute); + $argumentList = QueryComposer::getAllAttributesFromComplexExpression($attribute); foreach ($argumentList as $argument) { $this->checkWhereArgument($argument, $type); } @@ -1770,21 +1777,26 @@ class SelectManager $key = $midKeys[1]; $part[$alias . 'Middle.' . $key] = $value; } + } else if ($relationType == 'hasMany') { $this->addLeftJoin([$link, $alias], $result); $part[$alias . '.id'] = $value; + } else if ($relationType == 'belongsTo') { $key = $seed->getRelationParam($link, 'key'); if (!empty($key)) { $part[$key] = $value; } + } else if ($relationType == 'hasOne') { $this->addLeftJoin([$link, $alias], $result); $part[$alias . '.id'] = $value; + } else { break;; } + $this->setDistinct(true, $result); break; @@ -1800,30 +1812,45 @@ class SelectManager $alias = $link . 'NotLinkedFilter' . strval(rand(10000, 99999)); if ($relationType == 'manyMany') { - $this->addLeftJoin([$link, $alias], $result); - $midKeys = $seed->getRelationParam($link, 'midKeys'); + $key = $seed->getRelationParam($link, 'midKeys')[1]; + + $this->addLeftJoin( + [ + $link, $alias, [$key => $value], + ], + $result + ); + + $part[$alias . 'Middle.' . $key] = null; - if (!empty($midKeys)) { - $key = $midKeys[1]; - $result['joinConditions'][$alias] = [$key => $value]; - $part[$alias . 'Middle.' . $key] = null; - } } else if ($relationType == 'hasMany') { - $this->addLeftJoin([$link, $alias], $result); - $result['joinConditions'][$alias] = ['id' => $value]; + $this->addLeftJoin( + [ + $link, $alias, ['id' => $value] + ], + $result + ); + $part[$alias . '.id'] = null; + } else if ($relationType == 'belongsTo') { $key = $seed->getRelationParam($link, 'key'); + if (!empty($key)) { $part[$key . '!='] = $value; } + } else if ($relationType == 'hasOne') { $this->addLeftJoin([$link, $alias], $result); + $part[$alias . '.id!='] = $value; + } else { break; } + $this->setDistinct(true, $result); + break; case 'arrayAnyOf': @@ -2320,7 +2347,7 @@ class SelectManager } $fullTextSearchColumnSanitizedList = []; - $query = $this->getEntityManager()->getQuery(); + $query = $this->getEntityManager()->getQueryComposer(); foreach ($fullTextSearchColumnList as $i => $field) { $fullTextSearchColumnSanitizedList[$i] = $query->sanitize($query->toDb($field)); } @@ -2744,7 +2771,7 @@ class SelectManager protected function applyLeftJoinsFromAttribute(string $attribute, array &$result) { if (strpos($attribute, ':') !== false) { - $argumentList = Query::getAllAttributesFromComplexExpression($attribute); + $argumentList = QueryComposer::getAllAttributesFromComplexExpression($attribute); foreach ($argumentList as $argument) { $this->applyLeftJoinsFromAttribute($argument, $result); } diff --git a/application/Espo/Core/Utils/Cron/Job.php b/application/Espo/Core/Utils/Cron/Job.php index b2092673de..2c5547a8e1 100644 --- a/application/Espo/Core/Utils/Cron/Job.php +++ b/application/Espo/Core/Utils/Cron/Job.php @@ -307,8 +307,6 @@ class Job */ public function removePendingJobDuplicates() { - $pdo = $this->getEntityManager()->getPDO(); - $duplicateJobList = $this->getEntityManager()->getRepository('Job') ->select(['scheduledJobId']) ->where([ @@ -352,14 +350,15 @@ class Job continue; } - $sql = $this->getEntityManager()->getQuery()->createDeleteQuery('Job', [ - 'whereClause' => [ + $delete = $this->entityManager->getQueryBuilder() + ->delete() + ->from('Job') + ->where([ 'id' => $jobIdList, - ] - ]); + ]) + ->build(); - $sth = $pdo->prepare($sql); - $sth->execute(); + $this->entityManager->getQueryExecutor()->run($delete); } } diff --git a/application/Espo/Core/Utils/Database/Schema/rebuildActions/Currency.php b/application/Espo/Core/Utils/Database/Schema/rebuildActions/Currency.php index c422aae180..99a5852d08 100644 --- a/application/Espo/Core/Utils/Database/Schema/rebuildActions/Currency.php +++ b/application/Espo/Core/Utils/Database/Schema/rebuildActions/Currency.php @@ -31,7 +31,6 @@ namespace Espo\Core\Utils\Database\Schema\rebuildActions; class Currency extends \Espo\Core\Utils\Database\Schema\BaseRebuildActions { - public function afterRebuild() { $defaultCurrency = $this->getConfig()->get('defaultCurrency'); @@ -39,16 +38,18 @@ class Currency extends \Espo\Core\Utils\Database\Schema\BaseRebuildActions $baseCurrency = $this->getConfig()->get('baseCurrency'); $currencyRates = $this->getConfig()->get('currencyRates'); - if ($defaultCurrency != $baseCurrency) { + if ($defaultCurrency !== $baseCurrency) { $currencyRates = $this->exchangeRates($baseCurrency, $defaultCurrency, $currencyRates); } $currencyRates[$defaultCurrency] = '1.00'; - $pdo = $this->getEntityManager()->getPDO(); + $delete = $this->getEntityManager()->getQueryBuilder() + ->delete() + ->from('Currency') + ->build(); - $sql = $this->getEntityManager()->getQuery()->createDeleteQuery('Currency'); - $pdo->prepare($sql)->execute(); + $this->getEntityManager()->getQueryExecutor()->run($delete); foreach ($currencyRates as $currencyName => $rate) { $this->getEntityManager()->createEntity('Currency', [ @@ -58,20 +59,12 @@ class Currency extends \Espo\Core\Utils\Database\Schema\BaseRebuildActions } } - /** - * Calculate exchange rates if defaultCurrency doesn't equals baseCurrency - * - * @param string $baseCurrency - * @param string $defaultCurrency - * @param array $currencyRates [description] - * @return array - List of new currency rates - */ - protected function exchangeRates($baseCurrency, $defaultCurrency, array $currencyRates) + protected function exchangeRates(string $baseCurrency, string $defaultCurrency, array $currencyRates) : array { $precision = 5; $defaultCurrencyRate = round(1 / $currencyRates[$defaultCurrency], $precision); - $exchangedRates = array(); + $exchangedRates = []; $exchangedRates[$baseCurrency] = $defaultCurrencyRate; unset($currencyRates[$baseCurrency], $currencyRates[$defaultCurrency]); @@ -82,6 +75,4 @@ class Currency extends \Espo\Core\Utils\Database\Schema\BaseRebuildActions return $exchangedRates; } - } - diff --git a/application/Espo/Core/Utils/EntityManager.php b/application/Espo/Core/Utils/EntityManager.php index 74a59e4c03..36ea0d3bb3 100644 --- a/application/Espo/Core/Utils/EntityManager.php +++ b/application/Espo/Core/Utils/EntityManager.php @@ -353,7 +353,11 @@ class EntityManager $entityDefsDataContents = $this->getFileManager()->getContents($filePath); $entityDefsDataContents = str_replace('{entityType}', $name, $entityDefsDataContents); $entityDefsDataContents = str_replace('{entityTypeLowerFirst}', lcfirst($name), $entityDefsDataContents); - $entityDefsDataContents = str_replace('{tableName}', $this->getEntityManager()->getQuery()->toDb($name), $entityDefsDataContents); + $entityDefsDataContents = str_replace( + '{tableName}', + $this->getEntityManager()->getQueryComposer()->toDb($name), + $entityDefsDataContents + ); foreach ($replaceData as $key => $value) { $entityDefsDataContents = str_replace('{'.$key.'}', $value, $entityDefsDataContents); } diff --git a/application/Espo/Core/Utils/Util.php b/application/Espo/Core/Utils/Util.php index f610b4cc05..ca979ff94d 100644 --- a/application/Espo/Core/Utils/Util.php +++ b/application/Espo/Core/Utils/Util.php @@ -29,8 +29,6 @@ namespace Espo\Core\Utils; -use \Espo\Core\Exceptions\Error; - class Util { /** diff --git a/application/Espo/Hooks/Common/Notifications.php b/application/Espo/Hooks/Common/Notifications.php index f8e73d8002..89ee170ffe 100644 --- a/application/Espo/Hooks/Common/Notifications.php +++ b/application/Espo/Hooks/Common/Notifications.php @@ -139,7 +139,7 @@ class Notifications public function afterRemove(Entity $entity) { - $query = $this->entityManager->getQuery(); + $query = $this->entityManager->getQueryComposer(); $sql = " DELETE FROM `notification` WHERE diff --git a/application/Espo/Hooks/Common/Stream.php b/application/Espo/Hooks/Common/Stream.php index 4c181df68b..e58eaeaf23 100644 --- a/application/Espo/Hooks/Common/Stream.php +++ b/application/Espo/Hooks/Common/Stream.php @@ -102,7 +102,7 @@ class Stream $this->getStreamService()->unfollowAllUsersFromEntity($entity); } - $query = $this->entityManager->getQuery(); + $query = $this->entityManager->getQueryComposer(); $sql = " UPDATE `note` SET `deleted` = 1, `modified_at` = '".date('Y-m-d H:i:s')."' diff --git a/application/Espo/Jobs/Cleanup.php b/application/Espo/Jobs/Cleanup.php index 47c6218d4f..d8f22ab9cf 100644 --- a/application/Espo/Jobs/Cleanup.php +++ b/application/Espo/Jobs/Cleanup.php @@ -118,7 +118,7 @@ class Cleanup implements Job protected function cleanupJobs() { - $select = $this->entityManager->createSelectBuilder() + $delete = $this->entityManager->getQueryBuilder()->delete() ->from('Job') ->where([ 'DATE:modifiedAt<' => $this->getCleanupJobFromDate(), @@ -126,9 +126,9 @@ class Cleanup implements Job ]) ->build(); - $this->entityManager->getQueryExecutor()->delete($select); + $this->entityManager->getQueryExecutor()->run($delete); - $select = $this->entityManager->createSelectBuilder() + $delete = $this->entityManager->getQueryBuilder()->delete() ->from('Job') ->where([ 'DATE:modifiedAt<' => $this->getCleanupJobFromDate(), @@ -137,12 +137,12 @@ class Cleanup implements Job ]) ->build(); - $this->entityManager->getQueryExecutor()->delete($select); + $this->entityManager->getQueryExecutor()->run($delete); } protected function cleanupUniqueIds() { - $select = $this->entityManager->createSelectBuilder() + $delete = $this->entityManager->getQueryBuilder()->delete() ->from('UniqueId') ->where([ 'terminateAt!=' => null, @@ -150,7 +150,7 @@ class Cleanup implements Job ]) ->build(); - $this->entityManager->getQueryExecutor()->delete($select); + $this->entityManager->getQueryExecutor()->run($delete); } protected function cleanupScheduledJobLog() @@ -180,7 +180,7 @@ class Cleanup implements Job $ignoreIdList[] = $logRecord->get('id'); } - $select = $this->entityManager->createSelectBuilder() + $delete = $this->entityManager->getQueryBuilder()->delete() ->from('ScheduledJobLogRecord') ->where([ 'scheduledJobId' => $scheduledJobId, @@ -189,7 +189,7 @@ class Cleanup implements Job ]) ->build(); - $this->entityManager->getQueryExecutor()->delete($select); + $this->entityManager->getQueryExecutor()->run($delete); } } @@ -199,14 +199,14 @@ class Cleanup implements Job $datetime = new \DateTime(); $datetime->modify($period); - $select = $this->entityManager->createSelectBuilder() + $delete = $this->entityManager->getQueryBuilder()->delete() ->from('ActionHistoryRecord') ->where([ 'DATE:createdAt<' => $datetime->format('Y-m-d'), ]) ->build(); - $this->entityManager->getQueryExecutor()->delete($select); + $this->entityManager->getQueryExecutor()->run($delete); } protected function cleanupAuthToken() @@ -215,7 +215,7 @@ class Cleanup implements Job $datetime = new \DateTime(); $datetime->modify($period); - $select = $this->entityManager->createSelectBuilder() + $delete = $this->entityManager->getQueryBuilder()->delete() ->from('AuthToken') ->where([ 'DATE:modifiedAt<' => $datetime->format('Y-m-d'), @@ -223,7 +223,7 @@ class Cleanup implements Job ]) ->build(); - $this->entityManager->getQueryExecutor()->delete($select); + $this->entityManager->getQueryExecutor()->run($delete); } protected function cleanupAuthLog() @@ -232,14 +232,14 @@ class Cleanup implements Job $datetime = new \DateTime(); $datetime->modify($period); - $select = $this->entityManager->createSelectBuilder() + $delete = $this->entityManager->getQueryBuilder()->delete() ->from('AuthLogRecord') ->where([ 'DATE:createdAt<' => $datetime->format('Y-m-d'), ]) ->build(); - $this->entityManager->getQueryExecutor()->delete($select); + $this->entityManager->getQueryExecutor()->run($delete); } protected function getCleanupJobFromDate() @@ -348,7 +348,7 @@ class Cleanup implements Job } } - $select = $this->entityManager->createSelectBuilder() + $delete = $this->entityManager->getQueryBuilder()->delete() ->from('Attachment') ->where([ 'deleted' => true, @@ -356,7 +356,7 @@ class Cleanup implements Job ]) ->build(); - $this->entityManager->getQueryExecutor()->delete($select); + $this->entityManager->getQueryExecutor()->run($delete); } protected function cleanupEmails() @@ -383,7 +383,7 @@ class Cleanup implements Job $this->entityManager->removeEntity($attachment); } - $select = $this->entityManager->createSelectBuilder() + $delete = $this->entityManager->getQueryBuilder()->delete() ->from('Email') ->where([ 'deleted' => true, @@ -391,16 +391,16 @@ class Cleanup implements Job ]) ->build(); - $this->entityManager->getQueryExecutor()->delete($select); + $this->entityManager->getQueryExecutor()->run($delete); - $select = $this->entityManager->createSelectBuilder() + $delete = $this->entityManager->getQueryBuilder()->delete() ->from('EmailUser') ->where([ 'emailId' => $id, ]) ->build(); - $this->entityManager->getQueryExecutor()->delete($select); + $this->entityManager->getQueryExecutor()->run($delete); } } @@ -452,7 +452,7 @@ class Cleanup implements Job $repository = $this->entityManager->getRepository($scope); $repository->deleteFromDb($entity->id); - $query = $this->entityManager->getQuery(); + $query = $this->entityManager->getQueryComposer(); foreach ($entity->getRelationList() as $relation) { if ($entity->getRelationType($relation) !== Entity::MANY_MANY) { @@ -492,12 +492,12 @@ class Cleanup implements Job continue; } - $select = $this->entityManager->createSelectBuilder() + $delete = $this->entityManager->getQueryBuilder()->delete() ->from($relationEntityType) ->where($where) ->build(); - $this->entityManager->getQueryExecutor()->delete($select); + $this->entityManager->getQueryExecutor()->run($delete); } catch (\Exception $e) { $GLOBALS['log']->error("Cleanup: " . $e->getMessage()); diff --git a/application/Espo/Modules/Crm/Core/Formula/Functions/ExtGroup/AccountGroup/FindByEmailAddressType.php b/application/Espo/Modules/Crm/Core/Formula/Functions/ExtGroup/AccountGroup/FindByEmailAddressType.php index 01e552e164..4f9803202c 100644 --- a/application/Espo/Modules/Crm/Core/Formula/Functions/ExtGroup/AccountGroup/FindByEmailAddressType.php +++ b/application/Espo/Modules/Crm/Core/Formula/Functions/ExtGroup/AccountGroup/FindByEmailAddressType.php @@ -85,7 +85,7 @@ class FindByEmailAddressType extends BaseFunction implements if ($contact) { if (!in_array($domain, $ignoreList)) { - $account = $em->getRepository('Account')->join(['contacts'])->where([ + $account = $em->getRepository('Account')->join('contacts')->where([ 'emailAddress*' => '%' . $domain, 'contacts.id' => $contact->id, ])->findOne(); diff --git a/application/Espo/Modules/Crm/Jobs/CheckEmailAccounts.php b/application/Espo/Modules/Crm/Jobs/CheckEmailAccounts.php index 797142a58e..e21911ce2a 100644 --- a/application/Espo/Modules/Crm/Jobs/CheckEmailAccounts.php +++ b/application/Espo/Modules/Crm/Jobs/CheckEmailAccounts.php @@ -77,11 +77,13 @@ class CheckEmailAccounts implements JobTargeted public function prepare(ScheduledJob $scheduledJob, string $executeTime) { - $collection = $this->entityManager->getRepository('EmailAccount')->join([['assignedUser', 'assignedUserAdditional']])->where([ - 'status' => 'Active', - 'useImap' => true, - 'assignedUserAdditional.isActive' => true, - ])->find(); + $collection = $this->entityManager->getRepository('EmailAccount') + ->join('assignedUser', 'assignedUserAdditional') + ->where([ + 'status' => 'Active', + 'useImap' => true, + 'assignedUserAdditional.isActive' => true, + ])->find(); foreach ($collection as $entity) { $running = $this->entityManager->getRepository('Job')->where([ diff --git a/application/Espo/Modules/Crm/Services/Activities.php b/application/Espo/Modules/Crm/Services/Activities.php index ce44c7ffa3..dfdec60c5c 100644 --- a/application/Espo/Modules/Crm/Services/Activities.php +++ b/application/Espo/Modules/Crm/Services/Activities.php @@ -167,7 +167,7 @@ class Activities implements $selectParams = $selectManager->mergeSelectParams($selectParams, $additinalSelectParams); - $sql = $this->getEntityManager()->getQuery()->createSelectQuery('Meeting', $selectParams); + $sql = $this->getEntityManager()->getQueryComposer()->createSelectQuery('Meeting', $selectParams); return $sql; } @@ -222,7 +222,7 @@ class Activities implements $selectParams = $selectManager->mergeSelectParams($selectParams, $additinalSelectParams); - $sql = $this->getEntityManager()->getQuery()->createSelectQuery('Call', $selectParams); + $sql = $this->getEntityManager()->getQueryComposer()->createSelectQuery('Call', $selectParams); return $sql; } @@ -272,7 +272,7 @@ class Activities implements $selectParams = $selectManager->mergeSelectParams($selectParams, $additinalSelectParams); - $sql = $this->getEntityManager()->getQuery()->createSelectQuery('Email', $selectParams); + $sql = $this->getEntityManager()->getQueryComposer()->createSelectQuery('Email', $selectParams); return $sql; } @@ -351,7 +351,7 @@ class Activities implements $selectParams = $selectManager->mergeSelectParams($selectParams, $additinalSelectParams); - $sql = $this->getEntityManager()->getQuery()->createSelectQuery('Meeting', $selectParams); + $sql = $this->getEntityManager()->getQueryComposer()->createSelectQuery('Meeting', $selectParams); if ($this->isPerson($scope)) { $link = null; @@ -383,7 +383,7 @@ class Activities implements $selectParams = $selectManager->mergeSelectParams($selectParams, $additinalSelectParams); - $sql .= ' UNION ' . $this->getEntityManager()->getQuery()->createSelectQuery('Meeting', $selectParams); + $sql .= ' UNION ' . $this->getEntityManager()->getQueryComposer()->createSelectQuery('Meeting', $selectParams); } } @@ -463,7 +463,7 @@ class Activities implements $selectParams = $selectManager->mergeSelectParams($selectParams, $additinalSelectParams); - $sql = $this->getEntityManager()->getQuery()->createSelectQuery('Call', $selectParams); + $sql = $this->getEntityManager()->getQueryComposer()->createSelectQuery('Call', $selectParams); if ($this->isPerson($scope)) { $link = null; @@ -495,7 +495,7 @@ class Activities implements $selectParams = $selectManager->mergeSelectParams($selectParams, $additinalSelectParams); - $sql .= ' UNION ' . $this->getEntityManager()->getQuery()->createSelectQuery('Call', $selectParams); + $sql .= ' UNION ' . $this->getEntityManager()->getQueryComposer()->createSelectQuery('Call', $selectParams); } } @@ -576,7 +576,7 @@ class Activities implements $selectParams = $selectManager->mergeSelectParams($selectParams, $additinalSelectParams); - $sql = $this->getEntityManager()->getQuery()->createSelectQuery('Email', $selectParams); + $sql = $this->getEntityManager()->getQueryComposer()->createSelectQuery('Email', $selectParams); if ($this->isPerson($scope) || $this->isCompany($scope)) { $selectParams = $baseSelectParams; @@ -600,7 +600,7 @@ class Activities implements $selectParams = $selectManager->mergeSelectParams($selectParams, $additinalSelectParams); - $sql .= "\n UNION \n" . $this->getEntityManager()->getQuery()->createSelectQuery('Email', $selectParams); + $sql .= "\n UNION \n" . $this->getEntityManager()->getQueryComposer()->createSelectQuery('Email', $selectParams); $selectParams = $baseSelectParams; $selectParams['customJoin'] .= " @@ -626,7 +626,7 @@ class Activities implements $selectParams = $selectManager->mergeSelectParams($selectParams, $additinalSelectParams); - $sql .= "\n UNION \n" . $this->getEntityManager()->getQuery()->createSelectQuery('Email', $selectParams); + $sql .= "\n UNION \n" . $this->getEntityManager()->getQueryComposer()->createSelectQuery('Email', $selectParams); } return $sql; @@ -804,7 +804,7 @@ class Activities implements $sql = $this->getActivitiesQuery($entity, $entityType, $statusList, $isHistory, $selectParams); - $query = $this->getEntityManager()->getQuery(); + $query = $this->getEntityManager()->getQueryComposer(); $seed = $this->getEntityManager()->getEntity($entityType); @@ -816,7 +816,7 @@ class Activities implements $sql = $query->limit($sql, $offset, $limit); - $collection = $this->getEntityManager()->getRepository($entityType)->findByQuery($sql); + $collection = $this->getEntityManager()->getRepository($entityType)->findBySql($sql); foreach ($collection as $e) { $service->loadAdditionalFieldsForList($e); @@ -843,7 +843,7 @@ class Activities implements ]; } - public function getActivities($scope, $id, $params = []) + public function getActivities(string $scope, string $id, array $params = []) { $entity = $this->getEntityManager()->getEntity($scope, $id); if (!$entity) { @@ -969,7 +969,7 @@ class Activities implements $selectManager->applyAccess($selectParams); } - return $this->getEntityManager()->getQuery()->createSelectQuery('Meeting', $selectParams); + return $this->getEntityManager()->getQueryComposer()->createSelectQuery('Meeting', $selectParams); } protected function getCalendarCallQuery($userId, $from, $to, $skipAcl) @@ -1025,7 +1025,7 @@ class Activities implements $selectManager->applyAccess($selectParams); } - return $this->getEntityManager()->getQuery()->createSelectQuery('Call', $selectParams); + return $this->getEntityManager()->getQueryComposer()->createSelectQuery('Call', $selectParams); } protected function getCalendarTaskQuery($userId, $from, $to, $skipAcl = false) @@ -1088,7 +1088,7 @@ class Activities implements $selectManager->applyAccess($selectParams); } - return $this->getEntityManager()->getQuery()->createSelectQuery('Task', $selectParams); + return $this->getEntityManager()->getQueryComposer()->createSelectQuery('Task', $selectParams); } protected function getCalendarSelectParams($scope, $userId, $from, $to, $skipAcl = false) @@ -1182,7 +1182,7 @@ class Activities implements $selectManager = $this->getSelectManagerFactory()->create($scope); if (method_exists($selectManager, 'getCalendarSelectParams')) { $selectParams = $selectManager->getCalendarSelectParams($userId, $from, $to, $skipAcl); - return $this->getEntityManager()->getQuery()->createSelectQuery($scope, $selectParams); + return $this->getEntityManager()->getQueryComposer()->createSelectQuery($scope, $selectParams); } $methodName = 'getCalendar' . $scope . 'Query'; @@ -1191,7 +1191,7 @@ class Activities implements } $selectParams = $this->getCalendarSelectParams($scope, $userId, $from, $to, $skipAcl); - return $this->getEntityManager()->getQuery()->createSelectQuery($scope, $selectParams); + return $this->getEntityManager()->getQueryComposer()->createSelectQuery($scope, $selectParams); } protected function getActivitiesQuery(Entity $entity, $scope, array $statusList = [], $isHistory = false, $additinalSelectParams = null) @@ -1211,7 +1211,7 @@ class Activities implements $selectParams = $selectManager->mergeSelectParams($selectParams, $additinalSelectParams); - return $this->getEntityManager()->getQuery()->createSelectQuery($scope, $selectParams); + return $this->getEntityManager()->getQueryComposer()->createSelectQuery($scope, $selectParams); } $methodName = 'getActivities' . $scope . 'Query'; @@ -1223,7 +1223,7 @@ class Activities implements $selectParams = $selectManager->mergeSelectParams($selectParams, $additinalSelectParams); - return $this->getEntityManager()->getQuery()->createSelectQuery($scope, $selectParams); + return $this->getEntityManager()->getQueryComposer()->createSelectQuery($scope, $selectParams); } protected function getActivitiesSelectParams(Entity $entity, $scope, array $statusList = [], $isHistory) @@ -1403,10 +1403,15 @@ class Activities implements $userIdList = []; - $userList = $this->getEntityManager()->getRepository('User')->select(['id', 'name'])->leftJoin([['teams', 'teams']])->where([ - 'isActive' => true, - 'teamsMiddle.teamId' => $teamIdList - ])->distinct()->find([], true); + $userList = $this->getEntityManager()->getRepository('User') + ->select(['id', 'name']) + ->leftJoin('teams') + ->where([ + 'isActive' => true, + 'teamsMiddle.teamId' => $teamIdList + ]) + ->distinct() + ->find(); $userNames = (object) []; @@ -1667,6 +1672,7 @@ class Activities implements public function getUpcomingActivities($userId, $params = [], $entityTypeList = null, $futureDays = null) { $user = $this->getEntityManager()->getEntity('User', $userId); + $this->accessCheck($user); if (!$entityTypeList) { @@ -1682,6 +1688,8 @@ class Activities implements $taskBeforeString = (new \DateTime())->modify('+' . $upcomingTaskFutureDays . ' days')->format('Y-m-d H:i:s'); $unionPartList = []; + + foreach ($entityTypeList as $entityType) { if (!$this->getMetadata()->get(['scopes', $entityType, 'activity']) && $entityType !== 'Task') continue; if (!$this->getAcl()->checkScope($entityType, 'read')) continue; @@ -1771,8 +1779,7 @@ class Activities implements ] ], $selectParams); } - - $sql = $this->getEntityManager()->getQuery()->createSelectQuery($entityType, $selectParams); + $sql = $this->getEntityManager()->getQueryComposer()->createSelectQuery($entityType, $selectParams); $unionPartList[] = '' . $sql . ''; } @@ -1817,7 +1824,7 @@ class Activities implements return [ 'total' => $totalCount, - 'list' => $entityDataList + 'list' => $entityDataList, ]; } } diff --git a/application/Espo/Modules/Crm/Services/Campaign.php b/application/Espo/Modules/Crm/Services/Campaign.php index 03aca85389..83c7c296e8 100644 --- a/application/Espo/Modules/Crm/Services/Campaign.php +++ b/application/Espo/Modules/Crm/Services/Campaign.php @@ -160,7 +160,7 @@ class Campaign extends \Espo\Services\Record implements $this->getEntityManager()->getRepository('Opportunity')->handleSelectParams($params); - $sql = $this->getEntityManager()->getQuery()->createSelectQuery('Opportunity', $params); + $sql = $this->getEntityManager()->getQueryComposer()->createSelectQuery('Opportunity', $params); $pdo = $this->getEntityManager()->getPDO(); $sth = $pdo->prepare($sql); diff --git a/application/Espo/Modules/Crm/Services/MassEmail.php b/application/Espo/Modules/Crm/Services/MassEmail.php index d219e6d191..2a52c72b26 100644 --- a/application/Espo/Modules/Crm/Services/MassEmail.php +++ b/application/Espo/Modules/Crm/Services/MassEmail.php @@ -86,21 +86,21 @@ class MassEmail extends \Espo\Services\Record implements { parent::afterDeleteEntity($massEmail); - $selectParams = $this->getEntityManager()->createSelectBuilder() + $delete = $this->getEntityManager()->getQueryBuilder() + ->delete() ->from('EmailQueueItem') ->where([ 'massEmailId' => $massEmail->id, ]) ->build(); - $sql = $this->getEntityManager()->getQuery()->createDeleteQuery('EmailQueueItem', $selectParams->getRaw()); - - $this->getEntityManager()->runQuery($sql); + $this->getEntityManager()->getQueryExecutor()->run($delete); } protected function cleanupQueueItems(Entity $massEmail) { - $selectParams = $this->getEntityManager()->createSelectBuilder() + $delete = $this->getEntityManager()->getQueryBuilder() + ->delete() ->from('EmailQueueItem') ->where([ 'massEmailId' => $massEmail->id, @@ -108,9 +108,7 @@ class MassEmail extends \Espo\Services\Record implements ]) ->build(); - $sql = $this->getEntityManager()->getQuery()->createDeleteQuery('EmailQueueItem', $selectParams->getRaw()); - - $this->getEntityManager()->runQuery($sql); + $this->getEntityManager()->getQueryExecutor()->run($delete); } public function createQueue(Entity $massEmail, bool $isTest = false, $additionalTargetList = []) diff --git a/application/Espo/Modules/Crm/Services/Opportunity.php b/application/Espo/Modules/Crm/Services/Opportunity.php index 43aa3132dd..51fb678cee 100644 --- a/application/Espo/Modules/Crm/Services/Opportunity.php +++ b/application/Espo/Modules/Crm/Services/Opportunity.php @@ -99,7 +99,7 @@ class Opportunity extends \Espo\Services\Record $this->getEntityManager()->getRepository('Opportunity')->handleSelectParams($selectParams); - $sql = $this->getEntityManager()->getQuery()->createSelectQuery('Opportunity', $selectParams); + $sql = $this->getEntityManager()->getQueryComposer()->createSelectQuery('Opportunity', $selectParams); $sth = $pdo->prepare($sql); $sth->execute(); @@ -176,7 +176,7 @@ class Opportunity extends \Espo\Services\Record $this->getEntityManager()->getRepository('Opportunity')->handleSelectParams($selectParams); - $sql = $this->getEntityManager()->getQuery()->createSelectQuery('Opportunity', $selectParams); + $sql = $this->getEntityManager()->getQueryComposer()->createSelectQuery('Opportunity', $selectParams); $sth = $pdo->prepare($sql); $sth->execute(); @@ -234,7 +234,7 @@ class Opportunity extends \Espo\Services\Record $this->getEntityManager()->getRepository('Opportunity')->handleSelectParams($selectParams); - $sql = $this->getEntityManager()->getQuery()->createSelectQuery('Opportunity', $selectParams); + $sql = $this->getEntityManager()->getQueryComposer()->createSelectQuery('Opportunity', $selectParams); $sth = $pdo->prepare($sql); $sth->execute(); @@ -294,7 +294,7 @@ class Opportunity extends \Espo\Services\Record $this->getEntityManager()->getRepository('Opportunity')->handleSelectParams($selectParams); - $sql = $this->getEntityManager()->getQuery()->createSelectQuery('Opportunity', $selectParams); + $sql = $this->getEntityManager()->getQueryComposer()->createSelectQuery('Opportunity', $selectParams); $sth = $pdo->prepare($sql); $sth->execute(); diff --git a/application/Espo/Modules/Crm/Services/TargetList.php b/application/Espo/Modules/Crm/Services/TargetList.php index 003d01585f..232a0d9a7f 100644 --- a/application/Espo/Modules/Crm/Services/TargetList.php +++ b/application/Espo/Modules/Crm/Services/TargetList.php @@ -111,7 +111,7 @@ class TargetList extends \Espo\Services\Record foreach ($this->targetsLinkList as $link) { $foreignEntityType = $entity->getRelationParam($link, 'entity'); - $count += $this->getEntityManager()->getRepository($foreignEntityType)->join(['targetLists'])->where([ + $count += $this->getEntityManager()->getRepository($foreignEntityType)->join('targetLists')->where([ 'targetListsMiddle.targetListId' => $entity->id, 'targetListsMiddle.optedOut' => 1, ])->count(); @@ -209,7 +209,7 @@ class TargetList extends \Espo\Services\Record } $pdo = $this->getEntityManager()->getPDO(); - $query = $this->getEntityManager()->getQuery(); + $query = $this->getEntityManager()->getQueryComposer(); $sql = null; switch ($link) { @@ -237,7 +237,7 @@ class TargetList extends \Espo\Services\Record protected function findLinkedOptedOut(string $id, array $params) : RecordCollection { $pdo = $this->getEntityManager()->getPDO(); - $query = $this->getEntityManager()->getQuery(); + $query = $this->getEntityManager()->getQueryComposer(); $sqlContact = $query->createSelectQuery('Contact', array( 'select' => ['id', 'name', 'createdAt', ['VALUE:Contact', '_scope']], diff --git a/application/Espo/ORM/Collection.php b/application/Espo/ORM/Collection.php index a57bb0c116..0f85dc2279 100644 --- a/application/Espo/ORM/Collection.php +++ b/application/Espo/ORM/Collection.php @@ -29,10 +29,12 @@ namespace Espo\ORM; +use Traversable; + /** * A collection of entities. */ -interface Collection extends \Traversable +interface Collection extends Traversable { /** * Get an array of StdClass objects. diff --git a/application/Espo/ORM/RDBQueryExecutor.php b/application/Espo/ORM/CollectionFactory.php similarity index 67% rename from application/Espo/ORM/RDBQueryExecutor.php rename to application/Espo/ORM/CollectionFactory.php index 8d5a969dc7..0da836f2f2 100644 --- a/application/Espo/ORM/RDBQueryExecutor.php +++ b/application/Espo/ORM/CollectionFactory.php @@ -29,12 +29,14 @@ namespace Espo\ORM; +use Espo\ORM\{ + QueryParams\Select, +}; + /** - * Executes queries by a given RDBSelect instances. - * - * @todo Add `select` method returning an array of StdClass objects. + * Creates collections. */ -class RDBQueryExecutor +class CollectionFactory { protected $entityManager; @@ -43,20 +45,23 @@ class RDBQueryExecutor $this->entityManager = $entityManager; } - public function update(RDBSelect $select, array $values) + public function create(?string $entityType = null, array $data = []) : EntityCollection { - $params = $select->getRawParams(); - $params['update'] = $values; - - $sql = $this->entityManager->getQuery()->createUpdateQuery($select->getEntityType(), $params); - - $this->entityManager->runQuery($sql, true); + return new EntityCollection($data, $entityType, $this->entityManager->getEntityFactory()); } - public function delete(RDBSelect $select) + public function createFromSql(string $entityType, string $sql) : SthCollection { - $sql = $this->entityManager->getQuery()->createDeleteQuery($select->getEntityType(), $select->getRawParams()); + return SthCollection::fromSql($entityType, $sql, $this->entityManager); + } - $this->entityManager->runQuery($sql, true); + public function createFromQuery(Select $query) : SthCollection + { + return SthCollection::fromQuery($query, $this->entityManager); + } + + public function createFromSthCollection(SthCollection $sthCollection) : EntityCollection + { + return EntityCollection::fromSthCollection($sthCollection); } } diff --git a/application/Espo/ORM/DB/Query/Base.php b/application/Espo/ORM/DB/Query/Base.php index cc6f5603e2..c3e85ec993 100644 --- a/application/Espo/ORM/DB/Query/Base.php +++ b/application/Espo/ORM/DB/Query/Base.php @@ -32,7 +32,7 @@ namespace Espo\ORM\DB\Query; /** * @deprecated */ -abstract class Base extends BaseQuery +abstract class Base extends Espo\ORM\QueryComposer\BaseQueryComposer { } diff --git a/application/Espo/ORM/EntityCollection.php b/application/Espo/ORM/EntityCollection.php index b8c408c6a1..c671055a5d 100644 --- a/application/Espo/ORM/EntityCollection.php +++ b/application/Espo/ORM/EntityCollection.php @@ -29,10 +29,15 @@ namespace Espo\ORM; +use Iterator; +use Countable; +use ArrayAccess; +use SeekableIterator; + /** * A standard collection of entities. It allocates a memory for all entities. */ -class EntityCollection implements \Iterator, \Countable, \ArrayAccess, \SeekableIterator, Collection +class EntityCollection implements Collection, Iterator, Countable, ArrayAccess, SeekableIterator { private $entityFactory = null; @@ -300,4 +305,19 @@ class EntityCollection implements \Iterator, \Countable, \ArrayAccess, \Seekable { return $this->isFetched; } + + public static function fromSthCollection(SthCollection $sthCollection) : self + { + $entityList = []; + + foreach ($sthCollection as $entity) { + $entityList[] = $entity; + } + + $obj = new EntityCollection($entityList, $sthCollection->getEntityType()); + + $obj->setAsFetched(); + + return $obj; + } } diff --git a/application/Espo/ORM/EntityManager.php b/application/Espo/ORM/EntityManager.php index 17bf19850c..0b74b654f3 100644 --- a/application/Espo/ORM/EntityManager.php +++ b/application/Espo/ORM/EntityManager.php @@ -29,15 +29,17 @@ namespace Espo\ORM; -use Espo\Core\Exceptions\Error; - -use Espo\ORM\DB\{ - Mapper, - Query\BaseQuery as Query, +use Espo\ORM\{ + Mapper\Mapper, + QueryComposer\QueryComposer, + Repository\RepositoryFactory, + Repository\Repository, }; use PDO; +use PDOStatement; use Exception; +use RuntimeException; /** * A central access point to ORM functionality. @@ -50,6 +52,8 @@ class EntityManager protected $entityFactory; + protected $collectionFactory; + protected $repositoryFactory; protected $mappers = []; @@ -60,7 +64,7 @@ class EntityManager protected $params = []; - protected $query; + protected $queryComposer; protected $defaultMapperName = 'RDB'; @@ -95,20 +99,43 @@ class EntityManager $this->repositoryFactory = $repositoryFactory; - $this->queryExecutor = new RDBQueryExecutor($this); + $this->initQueryComposer(); + + $this->queryExecutor = new QueryExecutor($this); + + $this->queryBuilder = new QueryBuilder(); + + $this->collectionFactory = new CollectionFactory($this); + } + + protected function initQueryComposer() + { + $className = $this->params['queryComposerClassName'] ?? null; + + if (!$className) { + $platform = $this->params['platform']; + $className = 'Espo\\ORM\\QueryComposer\\' . ucfirst($platform) . 'QueryComposer'; + } + + if (!$className || !class_exists($className)) { + throw new RuntimeException("Query composer {$name} could not be created."); + } + + $this->queryComposer = new $className($this->getPDO(), $this->entityFactory, $this->metadata); } /** - * Get a Query. + * @todo Remove in v7.0. + * @deprecated */ - public function getQuery() : Query + public function getQuery() : QueryComposer { - if (empty($this->query)) { - $platform = $this->params['platform']; - $className = 'Espo\\ORM\\DB\\Query\\' . ucfirst($platform) . 'Query'; - $this->query = new $className($this->getPDO(), $this->entityFactory, $this->metadata); - } - return $this->query; + return $this->queryComposer; + } + + public function getQueryComposer() : QueryComposer + { + return $this->queryComposer; } protected function getMapperClassName(string $name) : string @@ -118,12 +145,12 @@ class EntityManager switch ($name) { case 'RDB': $platform = $this->params['platform']; - $className = 'Espo\\ORM\\DB\\' . ucfirst($platform) . 'Mapper'; + $className = 'Espo\\ORM\\Mapper\\' . ucfirst($platform) . 'Mapper'; break; } if (!$className || !class_exists($className)) { - throw new Error("Mapper {$name} does not exist."); + throw new RuntimeException("Mapper '{$name}' does not exist."); } return $className; @@ -140,7 +167,7 @@ class EntityManager if (empty($this->mappers[$className])) { $this->mappers[$className] = new $className( - $this->getPDO(), $this->entityFactory, $this->getQuery(), $this->metadata + $this->getPDO(), $this->entityFactory, $this->collectionFactory, $this->getQueryComposer(), $this->metadata ); } @@ -187,7 +214,7 @@ class EntityManager public function getEntity(string $entityType, ?string $id = null) : ?Entity { if (!$this->hasRepository($entityType)) { - throw new Error("ORM: Repository '{$entityType}' does not exist."); + throw new RuntimeException("ORM: Repository '{$entityType}' does not exist."); } return $this->getRepository($entityType)->get($id); @@ -214,7 +241,7 @@ class EntityManager } /** - * Create entity (store it in database). + * Create entity (store it in a database). * * @param StdClass|array $data Entity attributes. */ @@ -222,13 +249,14 @@ class EntityManager { $entity = $this->getEntity($entityType); $entity->set($data); + $this->saveEntity($entity, $options); return $entity; } /** - * Fetch an entity (from database). + * Fetch an entity (from a database). */ public function fetchEntity(string $entityType, string $id) : ?Entity { @@ -253,7 +281,7 @@ class EntityManager public function getRepository(string $entityType) : ?Repository { if (!$this->hasRepository($entityType)) { - throw new Error("Repository '{$entityType}' does not exist."); + throw new RuntimeException("Repository '{$entityType}' does not exist."); } if (empty($this->repositoryHash[$entityType])) { @@ -264,13 +292,17 @@ class EntityManager } /** - * Create a select builder. + * Get a query builder. */ - public function createSelectBuilder() : RDBSelectBuilder + public function getQueryBuilder() : QueryBuilder { - return new RDBSelectBuilder($this); + return $this->queryBuilder; } + /** + * @deprecated + * @todo Remove. + */ public function setMetadata(array $data) { $this->metadata->setData($data); @@ -282,7 +314,7 @@ class EntityManager } /** - * Get an instance of PDO. + * Get a PDO instance. */ public function getPDO() : PDO { @@ -294,19 +326,11 @@ class EntityManager } /** - * Create a collection. Entity type can be omitted. + * Create a collection. An entity type can be omitted. */ public function createCollection(?string $entityType = null, array $data = []) : EntityCollection { - return new EntityCollection($data, $entityType, $this->entityFactory); - } - - /** - * Create an STH collection. An STH collection is preferable when a select query returns a large number of rows. - */ - public function createSthCollection(string $entityType, array $selectParams = []) : SthCollection - { - return new SthCollection($entityType, $this, $selectParams); + return $this->collectionFactory->create($data, $entityType); } public function getEntityFactory() : EntityFactory @@ -314,30 +338,56 @@ class EntityManager return $this->entityFactory; } + public function getCollectionFactory() : CollectionFactory + { + return $this->collectionFactory; + } + /** * Get a Query Executor. */ - public function getQueryExecutor() : RDBQueryExecutor + public function getQueryExecutor() : QueryExecutor { return $this->queryExecutor; } /** - * Run a query. Returns a result. + * Run a Query. + */ + public function runQuery(Query $query) : PDOStatement + { + return $this->queryExecutor->run($query); + } + + /** + * Run a SQL query. * * @param $rerunIfDeadlock Query will be re-run if a deadlock occurs. */ - public function runQuery(string $query, bool $rerunIfDeadlock = false) + public function runSql(string $sql, bool $rerunIfDeadlock = false) : PDOStatement { + $pdoStatement = null; + try { - return $this->getPDO()->query($query); + $pdoStatement = $this->getPDO()->query($sql); } catch (Exception $e) { - if ($rerunIfDeadlock) { - if (isset($e->errorInfo) && $e->errorInfo[0] == 40001 && $e->errorInfo[1] == 1213) { - return $this->getPDO()->query($query); - } + if (!$rerunIfDeadlock) { + throw $e; + } + + if (isset($e->errorInfo) && $e->errorInfo[0] == 40001 && $e->errorInfo[1] == 1213) { + $pdoStatement = $this->getPDO()->query($sql); + } + + if (!$pdoStatement) { + throw $e; } - throw $e; } + + if (!$pdoStatement) { + throw new RuntimeException("Query execution failure."); + } + + return $pdoStatement; } } diff --git a/application/Espo/ORM/DB/BaseMapper.php b/application/Espo/ORM/Mapper/BaseMapper.php similarity index 67% rename from application/Espo/ORM/DB/BaseMapper.php rename to application/Espo/ORM/Mapper/BaseMapper.php index 33df6a2f0e..1c3ac0270b 100644 --- a/application/Espo/ORM/DB/BaseMapper.php +++ b/application/Espo/ORM/Mapper/BaseMapper.php @@ -27,16 +27,21 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM\DB; +namespace Espo\ORM\Mapper; use Espo\ORM\{ Entity, Collection, EntityFactory, + CollectionFactory, Metadata, - DB\Query\BaseQuery as Query, + QueryComposer\QueryComposer, EntityCollection, - Sth2Collection, + SthCollection, + QueryParams\Select, + QueryParams\Update, + QueryParams\Delete, + QueryParams\Insert, }; use PDO; @@ -49,7 +54,9 @@ use RuntimeException; */ abstract class BaseMapper implements Mapper { - public $pdo; + const ATTRIBUTE_DELETED = 'deleted'; + + protected $pdo; protected $entityFactroy; @@ -61,93 +68,117 @@ abstract class BaseMapper implements Mapper protected $aliasesCache = []; - protected $collectionClass = EntityCollection::class; - - protected $sthCollectionClass = Sth2Collection::class; - - public function __construct(PDO $pdo, EntityFactory $entityFactory, Query $query, Metadata $metadata) - { + public function __construct( + PDO $pdo, EntityFactory $entityFactory, CollectionFactory $collectionFactory, QueryComposer $queryComposer, Metadata $metadata + ) { $this->pdo = $pdo; - $this->query = $query; + $this->queryComposer = $queryComposer; $this->entityFactory = $entityFactory; + $this->collectionFactory = $collectionFactory; $this->metadata = $metadata; $this->helper = new Helper($metadata); } /** - * Get a single entity from DB by ID. + * Get the first entity from DB. */ - public function selectById(Entity $entity, string $id, ?array $params = null) : ?Entity + public function selectOne(Select $select) : ?Entity { - $params = $params ?? []; + $entityType = $select->getFrom(); - if (!array_key_exists('whereClause', $params)) { - $params['whereClause'] = []; - } + $entity = $this->entityFactory->create($entityType); - $params['whereClause']['id'] = $id; - - $sql = $this->query->createSelectQuery($entity->getEntityType(), $params); + $sql = $this->queryComposer->compose($select); $ps = $this->pdo->query($sql); - if ($ps) { - foreach ($ps as $row) { - $entity = $this->fromRow($entity, $row); - $entity->setAsFetched(); - return $entity; - } + if (!$ps) { + return null; } + + foreach ($ps as $row) { + $this->populateEntityFromRow($entity, $row); + $entity->setAsFetched(); + + return $entity; + } + return null; } /** * Get a number of entities in DB. */ - public function count(Entity $entity, ?array $params = null) : int + public function count(Select $select) : int { - return (int) $this->aggregate($entity, $params, 'COUNT', 'id'); + return (int) $this->aggregate($select, 'COUNT', 'id'); } - public function max(Entity $entity, ?array $params, string $attribute) + public function max(Select $select, string $attribute) { - return $this->aggregate($entity, $params, 'MAX', $attribute); + $value = $this->aggregate($select, 'MAX', $attribute); + + return $this->castToNumber($value); } - public function min(Entity $entity, ?array $params, string $attribute) + public function min(Select $select, string $attribute) { - return $this->aggregate($entity, $params, 'MIN', $attribute); + $value = $this->aggregate($select, 'MIN', $attribute); + + return $this->castToNumber($value); } - public function sum(Entity $entity, ?array $params, string $attribute) + public function sum(Select $select, string $attribute) { - return $this->aggregate($entity, $params, 'SUM', $attribute); + $value = $this->aggregate($select, 'SUM', $attribute); + + return $this->castToNumber($value); + } + + protected function castToNumber($value) + { + if (is_int($value) || is_float($value)) { + return $value; + } + + if (!is_string($value)) { + return 0; + } + + if (strpos($value, '.') !== false) { + return (float) $value; + } + + return (int) $value; } /** * Select enities from DB. */ - public function select(Entity $entity, ?array $params = null) : Collection + public function select(Select $select) : Collection { - $sql = $this->query->createSelectQuery($entity->getEntityType(), $params); + $entityType = $select->getFrom(); - return $this->selectByQuery($entity, $sql, $params); + $sql = $this->queryComposer->compose($select); + + return $this->selectBySqlInternal($entityType, $sql, $select->isSth()); } /** * Select enities from DB by a SQL query. */ - public function selectByQuery(Entity $entity, string $sql, ?array $params = null) : Collection + public function selectBySql(string $entityType, string $sql) : SthCollection + { + return $this->selectBySqlInternal($entityType, $sql, true); + } + + protected function selectBySqlInternal(string $entityType, string $sql, bool $returnSthCollection = false) : Collection { $params = $params ?? []; - if ($params['returnSthCollection'] ?? false) { - $collection = $this->createSthCollection($entity->getEntityType()); - $collection->setQuery($sql); - $collection->setAsFetched(); - - return $collection; + if ($returnSthCollection) { + return $this->collectionFactory->createFromSql($entityType, $sql); } $dataList = []; @@ -156,63 +187,63 @@ abstract class BaseMapper implements Mapper $dataList = $ps->fetchAll(); } - $collection = $this->createCollection($entity->getEntityType(), $dataList); + $collection = $this->collectionFactory->create($entityType, $dataList); $collection->setAsFetched(); return $collection; } - protected function createCollection(string $entityType, ?array $dataList = []) + public function aggregate(Select $select, string $aggregation, string $aggregationBy) { - return new $this->collectionClass($dataList, $entityType, $this->entityFactory); - } + $entityType = $select->getFrom(); - protected function createSthCollection(string $entityType) - { - return new $this->sthCollectionClass($entityType, $this->entityFactory, $this->query, $this->pdo);; - } + $entity = $this->entityFactory->create($entityType); - public function aggregate(Entity $entity, ?array $params, string $aggregation, string $aggregationBy) - { if (empty($aggregation) || !$entity->hasAttribute($aggregationBy)) { - return null; + throw new RuntimeException(); } - $params = $params ?? []; + $params = $select->getRawParams(); $params['aggregation'] = $aggregation; $params['aggregationBy'] = $aggregationBy; - $sql = $this->query->createSelectQuery($entity->getEntityType(), $params); + $select = Select::fromRaw($params); + + $sql = $this->queryComposer->compose($select); $ps = $this->pdo->query($sql); - if ($ps) { - foreach ($ps as $row) { - return $row['value']; - } + if (!$ps) { + return null; } - return null; + foreach ($ps as $row) { + return $row['value']; + } } /** * Select related entities from DB. */ - public function selectRelated(Entity $entity, string $relationName, ?array $params = null) + public function selectRelated(Entity $entity, string $relationName, ?Select $select = null) { - return $this->selectRelatedInternal($entity, $relationName, $params); + return $this->selectRelatedInternal($entity, $relationName, $select); } - protected function selectRelatedInternal(Entity $entity, string $relationName, ?array $params = null, bool $returnTotalCount = false) + protected function selectRelatedInternal(Entity $entity, string $relationName, ?Select $select = null, bool $returnTotalCount = false) { - $params = $params ?? []; + $params = []; + + if ($select) { + $params = $select->getRawParams(); + } $entityType = $entity->getEntityType(); $relDefs = $entity->getRelations()[$relationName]; - if (!isset($relDefs['type'])) { + if (!$entity->getRelationType($relationName)) { throw new LogicException( "Missing 'type' in definition for relationship {$relationName} in {entityType} entity." ); @@ -225,7 +256,7 @@ abstract class BaseMapper implements Mapper ); } - $relEntityType = (!empty($relDefs['class'])) ? $relDefs['class'] : $relDefs['entity']; + $relEntityType = $entity->getRelationParam($relationName, 'entity'); $relEntity = $this->entityFactory->create($relEntityType); } @@ -250,8 +281,9 @@ abstract class BaseMapper implements Mapper $params['whereClause'][$foreignKey] = $entity->get($key); $params['offset'] = 0; $params['limit'] = 1; + $params['from'] = $relEntity->getEntityType(); - $sql = $this->query->createSelectQuery($relEntity->getEntityType(), $params); + $sql = $this->queryComposer->compose(Select::fromRaw($params)); $ps = $this->pdo->query($sql); @@ -261,13 +293,13 @@ abstract class BaseMapper implements Mapper if ($returnTotalCount) { foreach ($ps as $row) { - return intval($row['value']); + return (int) $row['value']; } return 0; } foreach ($ps as $row) { - $relEntity = $this->fromRow($relEntity, $row); + $this->populateEntityFromRow($relEntity, $row); $relEntity->setAsFetched(); return $relEntity; @@ -296,14 +328,13 @@ abstract class BaseMapper implements Mapper $resultDataList = []; - $sql = $this->query->createSelectQuery($relEntity->getEntityType(), $params); + $params['from'] = $relEntity->getEntityType(); + + $sql = $this->queryComposer->compose(Select::fromRaw($params)); if (!$returnTotalCount) { if (!empty($params['returnSthCollection']) && $relType !== Entity::HAS_ONE) { - $collection = $this->createSthCollection($relEntity->getEntityType()); - $collection->setQuery($sql); - $collection->setAsFetched(); - return $collection; + return $this->collectionFactory->createFromSql($relEntity->getEntityType(), $sql); } } @@ -315,7 +346,7 @@ abstract class BaseMapper implements Mapper if ($returnTotalCount) { foreach ($ps as $row) { - return intval($row['value']); + return (int) $row['value']; } return 0; } @@ -324,7 +355,7 @@ abstract class BaseMapper implements Mapper if ($relType == Entity::HAS_ONE) { if (count($resultDataList)) { - $relEntity = $this->fromRow($relEntity, $resultDataList[0]); + $this->populateEntityFromRow($relEntity, $resultDataList[0]); $relEntity->setAsFetched(); return $relEntity; @@ -332,34 +363,26 @@ abstract class BaseMapper implements Mapper return null; } - $collection = $this->createCollection($relEntity->getEntityType(), $resultDataList); + $collection = $this->collectionFactory->create($relEntity->getEntityType(), $resultDataList); $collection->setAsFetched(); return $collection; case Entity::MANY_MANY: - $additionalColumnsConditions = null; - if (!empty($params['additionalColumnsConditions'])) { - $additionalColumnsConditions = $params['additionalColumnsConditions']; - } $params['joins'] = $params['joins'] ?? []; - $params['joins'][] = $this->getManyManyJoin($entity, $relationName, $additionalColumnsConditions); + $params['joins'][] = $this->getManyManyJoin($entity, $relationName); - $params['relationName'] = $relDefs['relationName']; + $params['from'] = $relEntity->getEntityType(); - $sql = $this->query->createSelectQuery($relEntity->getEntityType(), $params); + $sql = $this->queryComposer->compose(Select::fromRaw($params)); $resultDataList = []; if (!$returnTotalCount) { if (!empty($params['returnSthCollection'])) { - $collection = $this->createSthCollection($relEntity->getEntityType()); - $collection->setQuery($sql); - $collection->setAsFetched(); - - return $collection; + return $this->collectionFactory->createFromSql($relEntity->getEntityType(), $sql); } } @@ -371,14 +394,14 @@ abstract class BaseMapper implements Mapper if ($returnTotalCount) { foreach ($ps as $row) { - return intval($row['value']); + return (int) $row['value']; } return null; } $resultDataList = $ps->fetchAll(); - $collection = $this->createCollection($relEntity->getEntityType(), $resultDataList); + $collection = $this->collectionFactory->create($relEntity->getEntityType(), $resultDataList); $collection->setAsFetched(); return $collection; @@ -388,9 +411,7 @@ abstract class BaseMapper implements Mapper $foreignEntityId = $entity->get($key); if (!$foreignEntityType || !$foreignEntityId) { - throw new LogicException( - "Bad definition for relationship {$relationName} in {$entityType} entity." - ); + return null; } $params['whereClause'][$foreignKey] = $foreignEntityId; @@ -399,7 +420,9 @@ abstract class BaseMapper implements Mapper $relEntity = $this->entityFactory->create($foreignEntityType); - $sql = $this->query->createSelectQuery($foreignEntityType, $params); + $params['from'] = $foreignEntityType; + + $sql = $this->queryComposer->compose(Select::fromRaw($params)); $ps = $this->pdo->query($sql); @@ -409,13 +432,13 @@ abstract class BaseMapper implements Mapper if ($returnTotalCount) { foreach ($ps as $row) { - return intval($row['value']); + return (int) $row['value']; } return 0; } foreach ($ps as $row) { - $relEntity = $this->fromRow($relEntity, $row); + $this->populateEntityFromRow($relEntity, $row); return $relEntity; } @@ -430,9 +453,9 @@ abstract class BaseMapper implements Mapper /** * Get a number of related enities in DB. */ - public function countRelated(Entity $entity, string $relationName, ?array $params = null) : int + public function countRelated(Entity $entity, string $relationName, ?Select $select = null) : int { - return (int) $this->selectRelatedInternal($entity, $relationName, $params, true); + return (int) $this->selectRelatedInternal($entity, $relationName, $select, true); } /** @@ -478,7 +501,7 @@ abstract class BaseMapper implements Mapper /** * Update relationship columns. */ - public function updateRelation(Entity $entity, string $relationName, ?string $id = null, array $columnData) : bool + public function updateRelationColumns(Entity $entity, string $relationName, ?string $id = null, array $columnData) : bool { if (empty($id) || empty($relationName)) { throw new RuntimeException("Can't update relation, empty ID or relation name."); @@ -513,7 +536,7 @@ abstract class BaseMapper implements Mapper $where = [ $nearKey => $entity->id, $distantKey => $id, - 'deleted' => false, + static::ATTRIBUTE_DELETED => false, ]; $conditions = $entity->getRelationParam($relationName, 'conditions') ?? []; @@ -522,12 +545,15 @@ abstract class BaseMapper implements Mapper $where[$k] = $value; } - $sql = $this->query->createUpdateQuery($middleName, [ - 'whereClause' => $where, - 'update' => $update, - ]); + $sql = $this->queryComposer->compose( + Update::fromRaw([ + 'from' => $middleName, + 'whereClause' => $where, + 'set' => $update, + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); return true; } @@ -570,7 +596,7 @@ abstract class BaseMapper implements Mapper $where = [ $nearKey => $entity->id, $distantKey => $id, - 'deleted' => false, + static::ATTRIBUTE_DELETED => false, ]; $conditions = $entity->getRelationParam($relationName, 'conditions') ?? []; @@ -579,10 +605,13 @@ abstract class BaseMapper implements Mapper $where[$k] = $value; } - $sql = $this->query->createSelectQuery($middleName, [ - 'select' => [[$column, 'value']], - 'whereClause' => $where, - ]); + $sql = $this->queryComposer->compose( + Select::fromRaw([ + 'from' => $middleName, + 'select' => [[$column, 'value']], + 'whereClause' => $where, + ]) + ); $ps = $this->pdo->query($sql); @@ -594,15 +623,15 @@ abstract class BaseMapper implements Mapper $value = $row['value']; if ($columnType == Entity::BOOL) { - return boolval($value); + return (bool) $value; } if ($columnType == Entity::INT) { - return intval($value); + return (int) $value; } if ($columnType == Entity::FLOAT) { - return floatval($value); + return (int) $value; } return $value; @@ -614,8 +643,10 @@ abstract class BaseMapper implements Mapper /** * Mass relate. */ - public function massRelate(Entity $entity, string $relationName, array $params = []) + public function massRelate(Entity $entity, string $relationName, Select $select) { + $params = $select->getRawParams(); + $id = $entity->id; if (empty($id) || empty($relationName)) { @@ -671,15 +702,18 @@ abstract class BaseMapper implements Mapper $params['from'] = $foreignEntityType; - $sql = $this->query->createInsertQuery($middleName, [ - 'columns' => $columns, - 'valuesSelectParams' => $params, - 'update' => [ - 'deleted' => false, - ], - ]); + $sql = $this->queryComposer->compose( + Insert::fromRaw([ + 'into' => $middleName, + 'columns' => $columns, + 'valuesSelectParams' => $params, + 'updateSet' => [ + static::ATTRIBUTE_DELETED => false, + ], + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); return; } @@ -687,14 +721,14 @@ abstract class BaseMapper implements Mapper throw new LogicException("Relation type '{$relType}' is not supported for mass relate."); } - protected function runQuery(string $query, bool $rerunIfDeadlock = false) + protected function runSql(string $sql, bool $rerunIfDeadlock = false) { try { - return $this->pdo->query($query); + return $this->pdo->query($sql); } catch (Exception $e) { if ($rerunIfDeadlock) { if (isset($e->errorInfo) && $e->errorInfo[0] == 40001 && $e->errorInfo[1] == 1213) { - return $this->pdo->query($query); + return $this->pdo->query($sql); } } throw $e; @@ -746,31 +780,37 @@ abstract class BaseMapper implements Mapper $foreignRelationName = $entity->getRelationParam($relationName, 'foreign'); if ($foreignRelationName && $relEntity->getRelationParam($foreignRelationName, 'type') === Entity::HAS_ONE) { - $sql = $this->query->createUpdateQuery($entityType, [ - 'whereClause' => [ - 'id!=' => $entity->id, - $key => $id, - 'deleted' => false, - ], - 'update' => [ - $key => NULL, - ], - ]); + $sql = $this->queryComposer->compose( + Update::fromRaw([ + 'from' => $entityType, + 'whereClause' => [ + 'id!=' => $entity->id, + $key => $id, + static::ATTRIBUTE_DELETED => false, + ], + 'set' => [ + $key => null, + ], + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); } - $sql = $this->query->createUpdateQuery($entityType, [ - 'whereClause' => [ - 'id' => $entity->id, - 'deleted' => false, - ], - 'update' => [ - $key => $relEntity->id, - ], - ]); + $sql = $this->queryComposer->compose( + Update::fromRaw([ + 'from' => $entityType, + 'whereClause' => [ + 'id' => $entity->id, + static::ATTRIBUTE_DELETED => false, + ], + 'set' => [ + $key => $relEntity->id, + ], + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); return true; @@ -778,51 +818,65 @@ abstract class BaseMapper implements Mapper $key = $relationName . 'Id'; $typeKey = $relationName . 'Type'; - $sql = $this->query->createUpdateQuery($entityType, [ - 'whereClause' => [ - 'id' => $entity->id, - 'deleted' => false, - ], - 'update' => [ - $key => $relEntity->id, - $typeKey => $relEntity->getEntityType(), - ], - ]); + $sql = $this->queryComposer->compose( + Update::fromRaw([ + 'from' => $entityType, + 'whereClause' => [ + 'id' => $entity->id, + static::ATTRIBUTE_DELETED => false, + ], + 'set' => [ + $key => $relEntity->id, + $typeKey => $relEntity->getEntityType(), + ], + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); return true; case Entity::HAS_ONE: $foreignKey = $keySet['foreignKey']; - if ($this->count($relEntity, ['whereClause' => ['id' => $id]]) === 0) { + $selectForCount = Select::fromRaw([ + 'from' => $relEntity->getEntityType(), + 'whereClause' => ['id' => $id], + ]); + + if ($this->count($selectForCount) === 0) { return false; } - $sql = $this->query->createUpdateQuery($relEntity->getEntityType(), [ - 'whereClause' => [ - $foreignKey => $entity->id, - 'deleted' => false, - ], - 'update' => [ - $foreignKey => NULL, - ], - ]); + $sql = $this->queryComposer->compose( + Update::fromRaw([ + 'from' => $relEntity->getEntityType(), + 'whereClause' => [ + $foreignKey => $entity->id, + static::ATTRIBUTE_DELETED => false, + ], + 'set' => [ + $foreignKey => NULL, + ], + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); - $sql = $this->query->createUpdateQuery($relEntity->getEntityType(), [ - 'whereClause' => [ - 'id' => $id, - 'deleted' => false, - ], - 'update' => [ - $foreignKey => $entity->id, - ], - ]); + $sql = $this->queryComposer->compose( + Update::fromRaw([ + 'from' => $relEntity->getEntityType(), + 'whereClause' => [ + 'id' => $id, + static::ATTRIBUTE_DELETED => false, + ], + 'set' => [ + $foreignKey => $entity->id, + ], + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); return true; @@ -831,7 +885,12 @@ abstract class BaseMapper implements Mapper $key = $keySet['key']; $foreignKey = $keySet['foreignKey']; - if ($this->count($relEntity, ['whereClause' => ['id' => $id]]) === 0) { + $selectForCount = Select::fromRaw([ + 'from' => $relEntity->getEntityType(), + 'whereClause' => ['id' => $id], + ]); + + if ($this->count($selectForCount) === 0) { return false; } @@ -844,15 +903,18 @@ abstract class BaseMapper implements Mapper $set[$foreignType] = $entity->getEntityType(); } - $sql = $this->query->createUpdateQuery($relEntity->getEntityType(), [ - 'whereClause' => [ - 'id' => $id, - 'deleted' => false, - ], - 'update' => $set, - ]); + $sql = $this->queryComposer->compose( + Update::fromRaw([ + 'from' => $relEntity->getEntityType(), + 'whereClause' => [ + 'id' => $id, + static::ATTRIBUTE_DELETED => false, + ], + 'set' => $set, + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); return true; @@ -860,7 +922,12 @@ abstract class BaseMapper implements Mapper $nearKey = $keySet['nearKey']; $distantKey = $keySet['distantKey']; - if ($this->count($relEntity, ['whereClause' => ['id' => $id]]) === 0) { + $selectForCount = Select::fromRaw([ + 'from' => $relEntity->getEntityType(), + 'whereClause' => ['id' => $id], + ]); + + if ($this->count($selectForCount) === 0) { return false; } @@ -882,11 +949,14 @@ abstract class BaseMapper implements Mapper $where[$f] = $v; } - $sql = $this->query->createSelectQuery($middleName, [ - 'select' => ['id'], - 'whereClause' => $where, - 'withDeleted' => true, - ]); + $sql = $this->queryComposer->compose( + Select::fromRaw([ + 'from' => $middleName, + 'select' => ['id'], + 'whereClause' => $where, + 'withDeleted' => true, + ]) + ); $ps = $this->pdo->query($sql); @@ -897,7 +967,7 @@ abstract class BaseMapper implements Mapper $columns = array_keys($values); $update = [ - 'deleted' => false, + static::ATTRIBUTE_DELETED => false, ]; foreach ($data as $column => $value) { @@ -906,31 +976,37 @@ abstract class BaseMapper implements Mapper $update[$column] = $value; } - $sql = $this->query->createInsertQuery($middleName, [ - 'columns' => $columns, - 'values' => $values, - 'update' => $update, - ]); + $sql = $this->queryComposer->compose( + Insert::fromRaw([ + 'into' => $middleName, + 'columns' => $columns, + 'values' => $values, + 'updateSet' => $update, + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); return true; } $update = [ - 'deleted' => false, + static::ATTRIBUTE_DELETED => false, ]; foreach ($data as $column => $value) { $update[$column] = $value; } - $sql = $this->query->createUpdateQuery($middleName, [ - 'whereClause' => $where, - 'update' => $update, - ]); + $sql = $this->queryComposer->compose( + Update::fromRaw([ + 'from' => $middleName, + 'whereClause' => $where, + 'set' => $update, + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); return true; } @@ -1007,14 +1083,17 @@ abstract class BaseMapper implements Mapper } } - $where['deleted'] = false; + $where[static::ATTRIBUTE_DELETED] = false; - $sql = $this->query->createUpdateQuery($entityType, [ - 'whereClause' => $where, - 'update' => $update, - ]); + $sql = $this->queryComposer->compose( + Update::fromRaw([ + 'from' => $entityType, + 'whereClause' => $where, + 'set' => $update, + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); return true; @@ -1041,14 +1120,17 @@ abstract class BaseMapper implements Mapper $update[$foreignType] = null; } - $where['deleted'] = false; + $where[static::ATTRIBUTE_DELETED] = false; - $sql = $this->query->createUpdateQuery($relEntity->getEntityType(), [ - 'whereClause' => $where, - 'update' => $update, - ]); + $sql = $this->queryComposer->compose( + Update::fromRaw([ + 'from' => $relEntity->getEntityType(), + 'whereClause' => $where, + 'set' => $update, + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); return true; @@ -1076,14 +1158,17 @@ abstract class BaseMapper implements Mapper $where[$f] = $v; } - $sql = $this->query->createUpdateQuery($middleName, [ - 'whereClause' => $where, - 'update' => [ - 'deleted' => true, - ], - ]); + $sql = $this->queryComposer->compose( + Update::fromRaw([ + 'from' => $middleName, + 'whereClause' => $where, + 'set' => [ + static::ATTRIBUTE_DELETED => true, + ], + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); return true; } @@ -1117,13 +1202,16 @@ abstract class BaseMapper implements Mapper $update = $onDuplicateSetMap = $this->getInsertOnDuplicateSetMap($entity, $onDuplicateUpdateAttributeList); } - $sql = $this->query->createInsertQuery($entity->getEntityType(), [ - 'columns' => $this->getInsertColumnList($entity), - 'values' => $this->getInsertValueMap($entity), - 'update' => $update, - ]); + $sql = $this->queryComposer->compose( + Insert::fromRaw([ + 'into' => $entity->getEntityType(), + 'columns' => $this->getInsertColumnList($entity), + 'values' => $this->getInsertValueMap($entity), + 'updateSet' => $update, + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); } /** @@ -1141,12 +1229,15 @@ abstract class BaseMapper implements Mapper $values[] = $this->getInsertValueMap($entity); } - $sql = $this->query->createInsertQuery($entity->getEntityType(), [ - 'columns' => $this->getInsertColumnList($collection[0]), - 'values' => $values, - ]); + $sql = $this->queryComposer->compose( + Insert::fromRaw([ + 'into' => $entity->getEntityType(), + 'columns' => $this->getInsertColumnList($collection[0]), + 'values' => $values, + ]) + ); - $this->runQuery($sql, true); + $this->runSql($sql, true); } protected function getInsertColumnList(Entity $entity) : array @@ -1221,13 +1312,16 @@ abstract class BaseMapper implements Mapper return; } - $sql = $this->query->createUpdateQuery($entity->getEntityType(), [ - 'whereClause' => [ - 'id' => $entity->id, - 'deleted' => false, - ], - 'update' => $valueMap, - ]); + $sql = $this->queryComposer->compose( + Update::fromRaw([ + 'from' => $entity->getEntityType(), + 'whereClause' => [ + 'id' => $entity->id, + static::ATTRIBUTE_DELETED => false, + ], + 'set' => $valueMap, + ]) + ); $this->pdo->query($sql); } @@ -1261,14 +1355,15 @@ abstract class BaseMapper implements Mapper ]; if ($onlyDeleted) { - $whereClause['deleted'] = true; + $whereClause[static::ATTRIBUTE_DELETED] = true; } - $sql = $this->query->createDeleteQuery($entityType, [ + $sql = $this->queryComposer->compose(Delete::fromRaw([ + 'from' => $entityType, 'whereClause' => $whereClause, - ]); + ])); - $this->runQuery($sql); + $this->runSql($sql); } /** @@ -1284,12 +1379,15 @@ abstract class BaseMapper implements Mapper 'id' => $id, ]; - $sql = $this->query->createUpdateQuery($entityType, [ - 'whereClause' => $whereClause, - 'update' => ['deleted' => false], - ]); + $sql = $this->queryComposer->compose( + Update::fromRaw([ + 'from' => $entityType, + 'whereClause' => $whereClause, + 'set' => [static::ATTRIBUTE_DELETED => false], + ]) + ); - $this->runQuery($sql); + $this->runSql($sql); } /** @@ -1297,7 +1395,7 @@ abstract class BaseMapper implements Mapper */ public function delete(Entity $entity) : bool { - $entity->set('deleted', true); + $entity->set(static::ATTRIBUTE_DELETED, true); return (booL) $this->update($entity); } @@ -1315,9 +1413,15 @@ abstract class BaseMapper implements Mapper !empty($defs['autoincrement']) || isset($defs['source']) && $defs['source'] != 'db' - ) continue; - if ($defs['type'] == Entity::FOREIGN) continue; + ) { + continue; + } + + if ($defs['type'] == Entity::FOREIGN) { + continue; + } } + $data[$attribute] = $entity->get($attribute); } } @@ -1325,11 +1429,9 @@ abstract class BaseMapper implements Mapper return $data; } - protected function fromRow(Entity $entity, $data) : Entity + protected function populateEntityFromRow(Entity $entity, $data) { $entity->set($data); - - return $entity; } protected function getManyManyJoin(Entity $entity, string $relationName, ?array $conditions = null) : array @@ -1357,7 +1459,7 @@ abstract class BaseMapper implements Mapper [ "{$distantKey}:" => $foreignKey, "{$nearKey}" => $entity->get($key), - "deleted" => false, + static::ATTRIBUTE_DELETED => false, ], ]; diff --git a/application/Espo/ORM/DB/Helper.php b/application/Espo/ORM/Mapper/Helper.php similarity index 99% rename from application/Espo/ORM/DB/Helper.php rename to application/Espo/ORM/Mapper/Helper.php index c66ea4f425..82f20b37bb 100644 --- a/application/Espo/ORM/DB/Helper.php +++ b/application/Espo/ORM/Mapper/Helper.php @@ -27,7 +27,7 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM\DB; +namespace Espo\ORM\Mapper; use Espo\ORM\{ Entity, diff --git a/application/Espo/ORM/DB/Mapper.php b/application/Espo/ORM/Mapper/Mapper.php similarity index 86% rename from application/Espo/ORM/DB/Mapper.php rename to application/Espo/ORM/Mapper/Mapper.php index 314de34542..9cd8d5c00e 100644 --- a/application/Espo/ORM/DB/Mapper.php +++ b/application/Espo/ORM/Mapper/Mapper.php @@ -27,45 +27,46 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM\DB; +namespace Espo\ORM\Mapper; use Espo\ORM\{ Entity, Collection, + QueryParams\Select, }; interface Mapper { /** - * Select an entity by ID. + * Get the first entity from DB. */ - public function selectById(Entity $entity, string $id, ?array $params = null) : ?Entity; + public function selectOne(Select $select) : ?Entity; /** * Select a list of entities according to given parameters. */ - public function select(Entity $entity, ?array $params = null) : Collection; + public function select(Select $select) : Collection; /** * Returns count of records according to given parameters. * * @return Record count. */ - public function count(Entity $entity, ?array $params = null) : int; + public function count(Select $select) : int; /** * Selects related entity or list of entities. * * @return List of entities or one entity. */ - public function selectRelated(Entity $entity, string $relationName, ?array $params = null); + public function selectRelated(Entity $entity, string $relationName, ?Select $select = null); /** * Returns count of related records according to given parameters. * * @return A number of records. */ - public function countRelated(Entity $entity, string $relationName, ?array $params = null) : int; + public function countRelated(Entity $entity, string $relationName, ?Select $select = null) : int; /** * Insert an entity into DB. diff --git a/application/Espo/ORM/DB/MysqlMapper.php b/application/Espo/ORM/Mapper/MysqlMapper.php similarity index 98% rename from application/Espo/ORM/DB/MysqlMapper.php rename to application/Espo/ORM/Mapper/MysqlMapper.php index 64067774c5..a087df7e3e 100644 --- a/application/Espo/ORM/DB/MysqlMapper.php +++ b/application/Espo/ORM/Mapper/MysqlMapper.php @@ -27,7 +27,7 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM\DB; +namespace Espo\ORM\Mapper; class MysqlMapper extends BaseMapper { diff --git a/application/Espo/ORM/Metadata.php b/application/Espo/ORM/Metadata.php index 6f002501db..5d6f7c4799 100644 --- a/application/Espo/ORM/Metadata.php +++ b/application/Espo/ORM/Metadata.php @@ -29,8 +29,11 @@ namespace Espo\ORM; -use Espo\Core\Utils\Util; +use InvalidArgumentException; +/** + * Metadata of all entities. + */ class Metadata { protected $data; @@ -50,13 +53,17 @@ class Metadata */ public function get(string $entityType, $key = null, $default = null) { - if (!array_key_exists($entityType, $this->data)) { + if (!$this->has($entityType)) { return null; } - $data = $this->data[$entityType]; - if (!$key) return $data; - return Util::getValueByKey($data, $key, $default); + $data = $this->data[$entityType]; + + if ($key === null) { + return $data; + } + + return self::getValueByKey($data, $key, $default); } /** @@ -64,9 +71,39 @@ class Metadata */ public function has(string $entityType) : bool { - if (!array_key_exists($entityType, $this->data)) { - return false; + return array_key_exists($entityType, $this->data); + } + + private static function getValueByKey(array $data, $key = null, $default = null) + { + if (!is_string($key) && !is_array($key) && !is_null($key)) { + throw new InvalidArgumentException(); } - return true; + + if (is_null($key) || empty($key)) { + return $data; + } + + if (!is_string($key) && !is_array($key)) { + throw new InvalidArgumentException(); + } + + $path = $key; + + if (is_string($key)) { + $path = explode('.', $key); + } + + $item = $data; + + foreach ($path as $k) { + if (!array_key_exists($k, $item)) { + return $default; + } + + $item = $item[$k]; + } + + return $item; } } diff --git a/application/Espo/ORM/QueryBuilder.php b/application/Espo/ORM/QueryBuilder.php new file mode 100644 index 0000000000..6afb3775d0 --- /dev/null +++ b/application/Espo/ORM/QueryBuilder.php @@ -0,0 +1,100 @@ +getShortName()); + + if (!method_exists($this, $methodName)) { + throw new RuntimeException("Can't clone an unsupported query."); + } + + return $this->$methodName()->clone($query); + } +} diff --git a/application/Espo/ORM/DB/Query/BaseQuery.php b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php similarity index 87% rename from application/Espo/ORM/DB/Query/BaseQuery.php rename to application/Espo/ORM/QueryComposer/BaseQueryComposer.php index 11763f5c96..eef8accc63 100644 --- a/application/Espo/ORM/DB/Query/BaseQuery.php +++ b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php @@ -27,23 +27,31 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM\DB\Query; +namespace Espo\ORM\QueryComposer; use Espo\ORM\{ Entity, EntityFactory, Metadata, - DB\Helper, - DB\Query\Functions, + Mapper\Helper, + QueryParams\Query as Query, + QueryParams\Select as SelectQuery, + QueryParams\Update as UpdateQuery, + QueryParams\Insert as InsertQuery, + QueryParams\Delete as DeleteQuery, }; use PDO; use RuntimeException; +use LogicException; /** * Composes SQL queries. + * + * @todo Add method that wraps into ``. + * @todo Break into sub-classes. Put sub-classes into `\Parts` namespace. */ -abstract class BaseQuery +abstract class BaseQueryComposer implements QueryComposer { protected static $paramList = [ 'select', @@ -67,7 +75,7 @@ abstract class BaseQuery 'maxTextColumnsLength', 'useIndex', 'withDeleted', - 'update', + 'set', ]; protected static $sqlOperators = [ @@ -162,24 +170,76 @@ abstract class BaseQuery } /** - * Compose a SELECT query. + * {@inheritdoc} */ - public function createSelectQuery(string $entityType, ?array $params = nul) : string + public function compose(Query $query) : string { - $params = $params ?? []; + if ($query instanceof SelectQuery) { + return $this->composeSelect($query); + } - return $this->createSelectQueryInternal($entityType, $params); + if ($query instanceof UpdateQuery) { + return $this->composeUpdate($query); + } + + if ($query instanceof InsertQuery) { + return $this->composeInsert($query); + } + + if ($query instanceof DeleteQuery) { + return $this->composeDelete($query); + } + + throw new RuntimeException("ORM Query: Query params could not be handled."); + } + + protected function composeSelect(SelectQuery $queryParam) : string + { + $params = $queryParam->getRawParams(); + + return $this->createSelectQueryInternal($params); + } + + protected function composeUpdate(UpdateQuery $queryParam) : string + { + $params = $queryParam->getRawParams(); + + return $this->createUpdateQuery($params); + } + + protected function composeDelete(DeleteQuery $queryParam) : string + { + $params = $queryParam->getRawParams(); + + return $this->createDeleteQuery($params); + } + + protected function composeInsert(InsertQuery $queryParam) : string + { + $params = $queryParam->getRawParams(); + + return $this->createInsertQuery($params); } /** - * Compose a DELETE query. + * @deprecated + * @todo Remove in v6.4. */ - public function createDeleteQuery(string $entityType, ?array $params = null) : string + public function createSelectQuery(string $entityType, ?array $params = null) : string { $params = $params ?? []; + $params['from'] = $entityType; + + return $this->compose(SelectQuery::fromRaw($params)); + } + + protected function createDeleteQuery(array $params = null) : string + { $params = $this->normilizeParams(self::DELETE_METHOD, $params); + $entityType = $params['from']; + $entity = $this->getSeed($entityType); $wherePart = $this->getWherePart($entity, $params['whereClause'], 'AND', $params); @@ -197,16 +257,13 @@ abstract class BaseQuery return $sql; } - /** - * Compose an UPDATE query. - */ - public function createUpdateQuery(string $entityType, ?array $params = null) : string + protected function createUpdateQuery(array $params = null) : string { - $params = $params ?? []; - $params = $this->normilizeParams(self::UPDATE_METHOD, $params); - $values = $params['update']; + $entityType = $params['from']; + + $values = $params['set']; $entity = $this->getSeed($entityType); @@ -228,13 +285,15 @@ abstract class BaseQuery return $sql; } - public function createInsertQuery(string $entityType, array $params) : string + protected function createInsertQuery(array $params) : string { $params = $this->normilizeInsertParams($params); + $entityType = $params['into']; + $columns = $params['columns']; $values = $params['values']; - $update = $params['update']; + $updateSet = $params['updateSet']; $valuesSelectParams = $params['valuesSelectParams']; $columnsPart = $this->getInsertColumnsPart($columns); @@ -243,8 +302,8 @@ abstract class BaseQuery $updatePart = null; - if ($update) { - $updatePart = $this->getInsertUpdatePart($update); + if ($updateSet) { + $updatePart = $this->getInsertUpdatePart($updateSet); } return $this->composeInsertQuery($this->toDb($entityType), $columnsPart, $valuesPart, $updatePart); @@ -330,10 +389,10 @@ abstract class BaseQuery } } - $update = $params['update'] = $params['update'] ?? null; + $updateSet = $params['updateSet'] = $params['updateSet'] ?? null; - if ($update && !is_array($update)) { - throw new RuntimeException("ORM Query: Bad 'update' param."); + if ($updateSet && !is_array($updateSet)) { + throw new RuntimeException("ORM Query: Bad 'updateSet' param."); } return $params; @@ -365,24 +424,26 @@ abstract class BaseQuery } if ($method !== self::UPDATE_METHOD) { - if (isset($params['update'])) { - throw new RuntimeException("ORM Query: Param 'update' is not allowed for '{$method}'."); + if (isset($params['set'])) { + throw new RuntimeException("ORM Query: Param 'set' is not allowed for '{$method}'."); } } - if (isset($params['update']) && !is_array($params['update'])) { - throw new RuntimeException("ORM Query: Param 'update' should be an array."); + if (isset($params['set']) && !is_array($params['set'])) { + throw new RuntimeException("ORM Query: Param 'set' should be an array."); } return $params; } - protected function createSelectQueryInternal(string $entityType, ?array $params = null) : string + protected function createSelectQueryInternal(array $params = null) : string { - $entity = $this->getSeed($entityType); - $params = $this->normilizeParams(self::SELECT_METHOD, $params); + $entityType = $params['from']; + + $entity = $this->getSeed($entityType); + $isAggregation = (bool) ($params['aggregation'] ?? null); $whereClause = $params['whereClause'] ?? []; @@ -405,9 +466,7 @@ abstract class BaseQuery } if (!$isAggregation) { - $selectPart = $this->getSelectPart( - $entity, $params['select'], $params['distinct'], $params['skipTextColumns'], $params['maxTextColumnsLength'], $params - ); + $selectPart = $this->getSelectPart($entity, $params); $orderPart = $this->getOrderPart($entity, $params['orderBy'], $params['order'], $params); @@ -579,26 +638,14 @@ abstract class BaseQuery } } if (count($extraSelect)) { - $extraSelectPart = $this->getSelectPart( - $entity, $extraSelect, false - ); + $extraSelectPart = $this->getSelectPart($entity, ['select' => $extraSelect]); + if ($extraSelectPart) { $selectPart .= ', ' . $extraSelectPart; } } } - if ( - !empty($params['additionalColumns']) && is_array($params['additionalColumns']) && !empty($params['relationName']) - ) { - $alias = $this->sanitizeSelectAlias(lcfirst($params['relationName'])); - - foreach ($params['additionalColumns'] as $column => $field) { - $itemAlias = $this->sanitizeSelectAlias($field); - $selectPart .= ", " . $alias . "." . $this->toDb($this->sanitize($column)) . " AS `{$itemAlias}`"; - } - } - if (!empty($params['additionalSelectColumns']) && is_array($params['additionalSelectColumns'])) { foreach ($params['additionalSelectColumns'] as $column => $field) { $itemAlias = $this->sanitizeSelectAlias($field); @@ -623,7 +670,7 @@ abstract class BaseQuery if (strpos($function, 'YEAR_') === 0 && $function !== 'YEAR_NUMBER') { $fiscalShift = substr($function, 5); if (is_numeric($fiscalShift)) { - $fiscalShift = intval($fiscalShift); + $fiscalShift = (int) $fiscalShift; $fiscalFirstMonth = $fiscalShift + 1; return @@ -636,7 +683,7 @@ abstract class BaseQuery if (strpos($function, 'QUARTER_') === 0 && $function !== 'QUARTER_NUMBER') { $fiscalShift = substr($function, 8); if (is_numeric($fiscalShift)) { - $fiscalShift = intval($fiscalShift); + $fiscalShift = (int) $fiscalShift; $fiscalFirstMonth = $fiscalShift + 1; $fiscalDistractedMonth = 12 - $fiscalFirstMonth; @@ -694,10 +741,12 @@ abstract class BaseQuery case 'DAY': return "DATE_FORMAT({$part}, '%Y-%m-%d')"; case 'WEEK_0': - return "CONCAT(SUBSTRING(YEARWEEK({$part}, 6), 1, 4), '/', TRIM(LEADING '0' FROM SUBSTRING(YEARWEEK({$part}, 6), 5, 2)))"; + return "CONCAT(SUBSTRING(YEARWEEK({$part}, 6), 1, 4), '/', ". + "TRIM(LEADING '0' FROM SUBSTRING(YEARWEEK({$part}, 6), 5, 2)))"; case 'WEEK': case 'WEEK_1': - return "CONCAT(SUBSTRING(YEARWEEK({$part}, 3), 1, 4), '/', TRIM(LEADING '0' FROM SUBSTRING(YEARWEEK({$part}, 3), 5, 2)))"; + return "CONCAT(SUBSTRING(YEARWEEK({$part}, 3), 1, 4), '/', ". + "TRIM(LEADING '0' FROM SUBSTRING(YEARWEEK({$part}, 3), 5, 2)))"; case 'QUARTER': return "CONCAT(YEAR({$part}), '_', QUARTER({$part}))"; case 'MONTH_NUMBER': @@ -763,7 +812,7 @@ abstract class BaseQuery $offsetHoursString = substr($offsetHoursString, 1, -1); } $offset = floatval($offsetHoursString); - $offsetHours = intval(floor(abs($offset))); + $offsetHours = (int) (floor(abs($offset))); $offsetMinutes = (abs($offset) - $offsetHours) * 60; $offsetString = str_pad((string) $offsetHours, 2, '0', \STR_PAD_LEFT) . @@ -857,6 +906,7 @@ abstract class BaseQuery $attribute = substr($attribute, 1, -1); } } + if (!empty($function)) { $function = strtoupper($this->sanitize($function)); } @@ -866,12 +916,13 @@ abstract class BaseQuery if ($function && in_array($function, Functions::$multipleArgumentsFunctionList)) { $arguments = $attribute; - $argumentList = $this->parseArgumentListFromFunctionContent($arguments); + $argumentList = self::parseArgumentListFromFunctionContent($arguments); $argumentPartList = []; foreach ($argumentList as $argument) { $argumentPartList[] = $this->getFunctionArgumentPart($entity, $argument, $distinct, $params); } + $part = implode(', ', $argumentPartList); } else { @@ -890,8 +941,9 @@ abstract class BaseQuery return self::getAllAttributesFromComplexExpressionImplementation($expression); } - protected static function getAllAttributesFromComplexExpressionImplementation(string $expression, ?array &$list = null) : array - { + protected static function getAllAttributesFromComplexExpressionImplementation( + string $expression, ?array &$list = null + ) : array { if (!$list) $list = []; $arguments = $expression; @@ -1117,6 +1169,7 @@ abstract class BaseQuery $params['leftJoins'][] = $j; } } + if (!empty($fieldDefs[$type]['joins'])) { foreach ($fieldDefs[$type]['joins'] as $j) { $jAlias = $this->obtainJoinAlias($j); @@ -1132,11 +1185,14 @@ abstract class BaseQuery } } + // Some fields may need additional select items add to a query. if (!empty($fieldDefs[$type]['additionalSelect'])) { $params['extraAdditionalSelect'] = $params['extraAdditionalSelect'] ?? []; + foreach ($fieldDefs[$type]['additionalSelect'] as $value) { $value = str_replace('{alias}', $alias, $value); $value = str_replace('{attribute}', $attribute, $value); + if (!in_array($value, $params['extraAdditionalSelect'])) { $params['extraAdditionalSelect'][] = $value; } @@ -1147,115 +1203,170 @@ abstract class BaseQuery return $part; } - protected function getSelectPart( - Entity $entity, - ?array $itemList = null, - bool $distinct = false, - bool $skipTextColumns = false, - ?int $maxTextColumnsLength = null, - ?array &$params = null - ) : string { - $select = ''; - $arr = []; - $specifiedList = is_array($itemList) ? true : false; + protected function getSelectPart(Entity $entity, array &$params) : string + { + $params = $params ?? []; - if (empty($itemList)) { - $attributeList = $entity->getAttributeList(); - } else { - $attributeList = $itemList; + $itemList = $params['select'] ?? []; + + $noSelectSpecified = !count($itemList); + + if (!$noSelectSpecified && $itemList[0] === '*') { + array_shift($itemList); + + foreach (array_reverse($entity->getAttributeList()) as $item) { + array_unshift($itemList, $item); + } } - if ($params && isset($params['additionalSelect'])) { + if ($noSelectSpecified) { + $itemList = $entity->getAttributeList(); + } + + // @todo Get rid of 'additionalSelect' parameter. + if (isset($params['additionalSelect'])) { foreach ($params['additionalSelect'] as $item) { - $attributeList[] = $item; + $itemList[] = $item; } } - foreach ($attributeList as $i => $attribute) { - if (is_string($attribute)) { - if (strpos($attribute, ':')) { - $attributeList[$i] = [ - $attribute, - $attribute - ]; + foreach ($itemList as $i => $item) { + if (is_string($item)) { + if (strpos($item, ':')) { + $itemList[$i] = [$item, $item]; continue; } + continue; } } - foreach ($attributeList as $attribute) { - $attributeType = null; - if (is_string($attribute)) { - $attributeType = $entity->getAttributeType($attribute); - } - if ($skipTextColumns) { - if ($attributeType === $entity::TEXT) { - continue; - } + $itemPairList = []; + + foreach ($itemList as $item) { + $pair = $this->getSelectPartItemPair($entity, $params, $item); + + if ($pair === null) { + continue; } - if (is_array($attribute) && count($attribute) == 2) { - if (stripos($attribute[0], 'VALUE:') === 0) { - $part = substr($attribute[0], 6); - if ($part !== false) { - $part = $this->quote($part); - } else { - $part = $this->quote(''); - } + $itemPairList[] = $pair; + } + + if (!count($itemPairList)) { + throw new RuntimeException("ORM Query: Select part can't be empty."); + } + + $selectPartItemList = []; + + foreach ($itemPairList as $item) { + $expression = $item[0]; + $alias = $this->sanitizeSelectAlias($item[1]); + + if ($expression === '' || $alias === '') { + throw new RuntimeException("Bad select expression."); + } + + $selectPartItemList[] = "{$expression} AS `{$alias}`"; + } + + $selectPart = implode(', ', $selectPartItemList); + + return $selectPart; + } + + protected function getSelectPartItemPair(Entity $entity, array &$params, $attribute) : ?array + { + $maxTextColumnsLength = $params['maxTextColumnsLength'] ?? null; + $skipTextColumns = $params['skipTextColumns'] ?? false; + $distinct = $params['distinct'] ?? false; + + $attributeType = null; + + if (!is_array($attribute) && !is_string($attribute)) { + throw new RuntimeException("ORM Query: Bad select item."); + } + + if (is_string($attribute)) { + $attributeType = $entity->getAttributeType($attribute); + } + + if ($skipTextColumns) { + if ($attributeType === $entity::TEXT) { + return null; + } + } + + if (is_array($attribute) && count($attribute) == 2) { + $alias = $attribute[1]; + + if (stripos($attribute[0], 'VALUE:') === 0) { + $part = substr($attribute[0], 6); + + if ($part !== false) { + $part = $this->quote($part); } else { - if (!$entity->hasAttribute($attribute[0])) { - $part = $this->convertComplexExpression($entity, $attribute[0], $distinct, $params); - } else { - $fieldDefs = $entity->getAttributes()[$attribute[0]]; - if (!empty($fieldDefs['select'])) { - $part = $this->getAttributeSql($entity, $attribute[0], 'select', $params); - } else { - if (!empty($fieldDefs['noSelect'])) { - continue; - } - if (!empty($fieldDefs['notStorable'])) { - continue; - } - $part = $this->getFieldPath($entity, $attribute[0], $params); - } - } + $part = $this->quote(''); } - $arr[] = $part . ' AS `' . $this->sanitizeSelectAlias($attribute[1]) . '`'; - continue; + return [$part, $alias]; } - $attribute = $this->sanitizeSelectItem($attribute); + if (!$entity->hasAttribute($attribute[0])) { + $part = $this->convertComplexExpression($entity, $attribute[0], $distinct, $params); - if ($entity->hasAttribute($attribute)) { - $fieldDefs = $entity->getAttributes()[$attribute]; - } else { - $part = $this->convertComplexExpression($entity, $attribute, $distinct, $params); - $arr[] = $part . ' AS `' . $this->sanitizeSelectAlias($attribute) . '`'; - continue; + return [$part, $alias]; } + $fieldDefs = $entity->getAttributes()[$attribute[0]]; + if (!empty($fieldDefs['select'])) { - $fieldPath = $this->getAttributeSql($entity, $attribute, 'select', $params); - } else { - if (!empty($fieldDefs['notStorable']) && ($fieldDefs['type'] ?? null) !== 'foreign') { - continue; - } - if ($attributeType === null) { - continue; - } - $fieldPath = $this->getFieldPath($entity, $attribute, $params); - if ($attributeType === $entity::TEXT && $maxTextColumnsLength !== null) { - $fieldPath = 'LEFT(' . $fieldPath . ', '. intval($maxTextColumnsLength) . ')'; - } + $part = $this->getAttributeSql($entity, $attribute[0], 'select', $params); + + return [$part, $alias]; } - $arr[] = $fieldPath . ' AS `' . $attribute . '`'; + if (!empty($fieldDefs['noSelect'])) { + return null; + } + + if (!empty($fieldDefs['notStorable'])) { + return null; + } + + $part = $this->getAttributePath($entity, $attribute[0], $params); + + return [$part, $alias]; } - $select = implode(', ', $arr); + if (!$entity->hasAttribute($attribute)) { + $expression = $this->sanitizeSelectItem($attribute); - return $select; + $part = $this->convertComplexExpression($entity, $expression, $distinct, $params); + + return [$part, $attribute]; + } + + if ($entity->getAttributeParam($attribute, 'select')) { + $fieldPath = $this->getAttributeSql($entity, $attribute, 'select', $params); + + return [$fieldPath, $attribute]; + } + + if ($attributeType === null) { + return null; + } + + if ($entity->getAttributeParam($attribute, 'notStorable') && $attributeType !== Entity::FOREIGN) { + return null; + } + + $fieldPath = $this->getAttributePath($entity, $attribute, $params); + + if ($attributeType === Entity::TEXT && $maxTextColumnsLength !== null) { + $fieldPath = 'LEFT(' . $fieldPath . ', '. strval($maxTextColumnsLength) . ')'; + } + + return [$fieldPath, $attribute]; } protected function getBelongsToJoinItemPart(Entity $entity, $relationName, $r = null, $alias = null) @@ -1276,7 +1387,8 @@ abstract class BaseQuery if ($alias) { return "JOIN `" . $this->toDb($r['entity']) . "` AS `" . $alias . "` ON ". - $this->toDb($entity->getEntityType()) . "." . $this->toDb($key) . " = " . $alias . "." . $this->toDb($foreignKey); + $this->toDb($entity->getEntityType()) . "." . $this->toDb($key) . " = " . + $alias . "." . $this->toDb($foreignKey); } } @@ -1362,7 +1474,7 @@ abstract class BaseQuery if ($useColumnAlias) { $fieldPath = '`'. $this->sanitizeSelectAlias($field) . '`'; } else { - $fieldPath = $this->getFieldPathForOrderBy($entity, $field, $params); + $fieldPath = $this->getAttributePathForOrderBy($entity, $field, $params); } $listQuoted = []; $list = array_reverse(explode(',', $list)); @@ -1403,7 +1515,7 @@ abstract class BaseQuery if ($useColumnAlias) { $fieldPath = '`'. $this->sanitizeSelectAlias($orderBy) . '`'; } else { - $fieldPath = $this->getFieldPathForOrderBy($entity, $orderBy, $params); + $fieldPath = $this->getAttributePathForOrderBy($entity, $orderBy, $params); } return "{$fieldPath} " . $order; @@ -1426,7 +1538,7 @@ abstract class BaseQuery return $sql; } - protected function getFieldPathForOrderBy(Entity $entity, string $orderBy, array $params) : ?string + protected function getAttributePathForOrderBy(Entity $entity, string $orderBy, array $params) : ?string { if (strpos($orderBy, '.') !== false || strpos($orderBy, ':') !== false) { return $this->convertComplexExpression( @@ -1437,7 +1549,7 @@ abstract class BaseQuery ); } - return $this->getFieldPath($entity, $orderBy, $params); + return $this->getAttributePath($entity, $orderBy, $params); } protected function getAggregationSelectPart(Entity $entity, string $aggregation, string $aggregationBy, bool $distinct = false) : ?string @@ -1461,6 +1573,7 @@ abstract class BaseQuery /** * Quote a value. + * @todo Make protected. */ public function quote($value) : string { @@ -1474,16 +1587,16 @@ abstract class BaseQuery } /** - * Convert a camelCase string to a corresponding representation for DB. + * {@inheritdoc) */ - public function toDb(string $attribute) : string + public function toDb(string $string) : string { - if (!array_key_exists($attribute, $this->attributeDbMapCache)) { - $attribute[0] = strtolower($attribute[0]); - $this->attributeDbMapCache[$attribute] = preg_replace_callback('/([A-Z])/', [$this, 'toDbMatchConversion'], $attribute); + if (!array_key_exists($string, $this->attributeDbMapCache)) { + $string[0] = strtolower($string[0]); + $this->attributeDbMapCache[$string] = preg_replace_callback('/([A-Z])/', [$this, 'toDbMatchConversion'], $string); } - return $this->attributeDbMapCache[$attribute]; + return $this->attributeDbMapCache[$string]; } protected function toDbMatchConversion(array $matches) : string @@ -1533,13 +1646,15 @@ abstract class BaseQuery return $aliases; } - protected function getFieldPath(Entity $entity, $field, &$params = null) : ?string + protected function getAttributePath(Entity $entity, string $attribute, ?array &$params = null) : ?string { - if (!isset($entity->getAttributes()[$field])) { + if (!isset($entity->getAttributes()[$attribute])) { return null; } - $f = $entity->getAttributes()[$field]; + $entityType = $entity->getEntityType(); + + $f = $entity->getAttributes()[$attribute]; $relationType = $f['type']; @@ -1553,46 +1668,48 @@ abstract class BaseQuery return null; } - $fieldPath = ''; - switch ($relationType) { case 'foreign': - if (isset($f['relation'])) { - $relationName = $f['relation']; + $relationName = $f['relation'] ?? null; - $foreign = $f['foreign']; - - if (is_array($foreign)) { - $wsCount = 0; - foreach ($foreign as $i => $value) { - if ($value == ' ') { - $foreign[$i] = '\' \''; - $wsCount ++; - } else { - $item = $this->getAlias($entity, $relationName) . '.' . $this->toDb($value); - - $foreign[$i] = "IFNULL({$item}, '')"; - } - } - - $fieldPath = 'TRIM(CONCAT(' . implode(', ', $foreign). '))'; - - if ($wsCount > 1) { - $fieldPath = "REPLACE({$fieldPath}, ' ', ' ')"; - } - - $fieldPath = "NULLIF({$fieldPath}, '')"; - } else { - $expression = $this->getAlias($entity, $relationName) . '.' . $foreign; - $fieldPath = $this->convertComplexExpression($entity, $expression, false, $params); - } + if (!$relationName) { + return null; } - break; - default: - $fieldPath = $this->toDb($entity->getEntityType()) . '.' . $this->toDb($this->sanitize($field)); + + $relationName = $f['relation']; + + $foreign = $f['foreign']; + + if (is_array($foreign)) { + $wsCount = 0; + + foreach ($foreign as $i => $value) { + if ($value == ' ') { + $foreign[$i] = '\' \''; + $wsCount ++; + } else { + $item = $this->getAlias($entity, $relationName) . '.' . $this->toDb($value); + + $foreign[$i] = "IFNULL({$item}, '')"; + } + } + + $path = 'TRIM(CONCAT(' . implode(', ', $foreign). '))'; + + if ($wsCount > 1) { + $path = "REPLACE({$path}, ' ', ' ')"; + } + + $path = "NULLIF({$path}, '')"; + } else { + $expression = $this->getAlias($entity, $relationName) . '.' . $foreign; + $path = $this->convertComplexExpression($entity, $expression, false, $params); + } + + return $path; } - return $fieldPath; + return $this->toDb($entityType) . '.' . $this->toDb($this->sanitize($attribute)); } protected function getWherePart( @@ -1777,7 +1894,7 @@ abstract class BaseQuery $params ); } else { - $leftPart = $this->getFieldPath($entity, $field, $params); + $leftPart = $this->getAttributePath($entity, $field, $params); } } } @@ -1864,10 +1981,7 @@ abstract class BaseQuery return $joinAlias; } - /** - * Convert a value to a string. - */ - public function stringifyValue($value) : string + protected function stringifyValue($value) : string { if (is_array($value)) { $arr = []; @@ -1892,6 +2006,7 @@ abstract class BaseQuery /** * Sanitize an alias for a SELECT statement. + * @todo Make protected? */ public function sanitizeSelectAlias(string $string) : string { @@ -2043,8 +2158,9 @@ abstract class BaseQuery } } - protected function getJoinItemPart(Entity $entity, $name, $isLeft = false, $conditions = [], $alias = null, array $params = []) - { + protected function getJoinItemPart( + Entity $entity, string $name, bool $isLeft = false, array $conditions = [], ?string $alias = null, array $params = [] + ) : string { $prefix = ($isLeft) ? 'LEFT ' : ''; if (!$entity->hasRelation($name)) { diff --git a/application/Espo/ORM/DB/Query/Functions.php b/application/Espo/ORM/QueryComposer/Functions.php similarity index 99% rename from application/Espo/ORM/DB/Query/Functions.php rename to application/Espo/ORM/QueryComposer/Functions.php index 7aeb59f701..c07111852f 100644 --- a/application/Espo/ORM/DB/Query/Functions.php +++ b/application/Espo/ORM/QueryComposer/Functions.php @@ -27,7 +27,7 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM\DB\Query; +namespace Espo\ORM\QueryComposer; class Functions { diff --git a/application/Espo/ORM/DB/Query/MysqlQuery.php b/application/Espo/ORM/QueryComposer/MysqlQueryComposer.php similarity index 95% rename from application/Espo/ORM/DB/Query/MysqlQuery.php rename to application/Espo/ORM/QueryComposer/MysqlQueryComposer.php index 3395ce7d3d..6c9fe5a659 100644 --- a/application/Espo/ORM/DB/Query/MysqlQuery.php +++ b/application/Espo/ORM/QueryComposer/MysqlQueryComposer.php @@ -27,9 +27,9 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM\DB\Query; +namespace Espo\ORM\QueryComposer; -class MysqlQuery extends BaseQuery +class MysqlQueryComposer extends BaseQueryComposer { public function limit(string $sql, ?int $offset = null, ?int $limit = null) : string { diff --git a/application/Espo/ORM/QueryComposer/QueryComposer.php b/application/Espo/ORM/QueryComposer/QueryComposer.php new file mode 100644 index 0000000000..13d439aad8 --- /dev/null +++ b/application/Espo/ORM/QueryComposer/QueryComposer.php @@ -0,0 +1,57 @@ +entityManager = $entityManager; + } + + public function run(Query $query) : PDOStatement + { + $sql = $this->entityManager->getQueryComposer()->compose($query); + + return $this->entityManager->runSql($sql, true); + } +} diff --git a/application/Espo/ORM/QueryParams/BaseBuilderTrait.php b/application/Espo/ORM/QueryParams/BaseBuilderTrait.php new file mode 100644 index 0000000000..71f1a70082 --- /dev/null +++ b/application/Espo/ORM/QueryParams/BaseBuilderTrait.php @@ -0,0 +1,56 @@ +params); + } + + protected function cloneInternal(Query $query) + { + if (!$this->isEmpty()) { + throw new RuntimeException("Clone can be called only on a new empty builder instance."); + } + + $this->params = $query->getRawParams(); + } + +} diff --git a/application/Espo/ORM/RDBSelect.php b/application/Espo/ORM/QueryParams/BaseTrait.php similarity index 76% rename from application/Espo/ORM/RDBSelect.php rename to application/Espo/ORM/QueryParams/BaseTrait.php index 17a99592c3..2d50d173c7 100644 --- a/application/Espo/ORM/RDBSelect.php +++ b/application/Espo/ORM/QueryParams/BaseTrait.php @@ -27,40 +27,35 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM; +namespace Espo\ORM\QueryParams; -/** - * Select parameters. - */ -class RDBSelect +trait BaseTrait { - protected $entityType; - - protected $params; - - public function __construct(string $entityType, array $params) - { - $this->entityType = $entityType; - $this->params = $params; - } - - /** - * Get an entity type. - */ - public function getEntityType() : string - { - return $this->entityType; - } + protected $params = []; /** * Get parameters in RAW format. */ public function getRawParams() : array { - $params = $this->params; + return $this->params; + } - $params['from'] = $this->entityType + /** + * Create from RAW params. + */ + public static function fromRaw(array $params) : self + { + $obj = new self(); - return $params; + $obj->validateRawParams($params); + + $obj->params = $params; + + return $obj; + } + + protected function validateRawParams(array $params) + { } } diff --git a/application/Espo/ORM/QueryParams/Builder.php b/application/Espo/ORM/QueryParams/Builder.php new file mode 100644 index 0000000000..373228376f --- /dev/null +++ b/application/Espo/ORM/QueryParams/Builder.php @@ -0,0 +1,49 @@ +validateRawParamsSelecting($params); + } +} diff --git a/application/Espo/ORM/QueryParams/DeleteBuilder.php b/application/Espo/ORM/QueryParams/DeleteBuilder.php new file mode 100644 index 0000000000..b7d5713a42 --- /dev/null +++ b/application/Espo/ORM/QueryParams/DeleteBuilder.php @@ -0,0 +1,63 @@ +params); + } + + /** + * Clone an existing query for a subsequent modifying and building. + */ + public function clone(Delete $query) : self + { + $this->cloneInternal($query); + + return $this; + } + + /** + * Apply LIMIT. + */ + public function limit(?int $limit = null) : self + { + $this->params['limit'] = $limit; + + return $this; + } +} diff --git a/application/Espo/ORM/QueryParams/Insert.php b/application/Espo/ORM/QueryParams/Insert.php new file mode 100644 index 0000000000..d42122ec49 --- /dev/null +++ b/application/Espo/ORM/QueryParams/Insert.php @@ -0,0 +1,69 @@ +params); + } + + /** + * Clone an existing query for a subsequent modifying and building. + */ + public function clone(Insert $query) : self + { + $this->cloneInternal($query); + } + + /** + * Into what entity type to insert. + */ + public function into(string $entityType) : self + { + $this->params['into'] = $entityType; + + return $this; + } + + /** + * What columns to set with values. A list of columns. + */ + public function columns(array $columns) : self + { + $this->params['columns'] = $columns; + + return $this; + } + + /** + * What values to insert. A key-value map or a list of key-value maps. + */ + public function values(array $values) : self + { + $this->params['values'] = $values; + + return $this; + } + + /** + * Values to set on duplicate key. A key-value map. + */ + public function updateSet(array $updateSet) : self + { + $this->params['updateSet'] = $updateSet; + + return $this; + } + + /** + * For a mass insert by a select sub-query. + */ + public function valuesQuery(Select $query) : self + { + $this->params['valuesSelectParams'] = $query->getRawParams(); + + return $this; + } +} diff --git a/application/Espo/ORM/Repositories/Removable.php b/application/Espo/ORM/QueryParams/Query.php similarity index 84% rename from application/Espo/ORM/Repositories/Removable.php rename to application/Espo/ORM/QueryParams/Query.php index bfd5af40bc..5ec30050b0 100644 --- a/application/Espo/ORM/Repositories/Removable.php +++ b/application/Espo/ORM/QueryParams/Query.php @@ -27,14 +27,15 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM\Repositories; +namespace Espo\ORM\QueryParams; -use Espo\ORM\Entity; - -interface Removable +/** + * Query parameters. Instances are immutable. Need to clone with a builder to get a copy for a further modification. + */ +interface Query { /** - * Remove a record (mark as deleted). + * Get parameters in RAW format. */ - public function remove(Entity $entity); + public function getRawParams() : array; } diff --git a/application/Espo/ORM/QueryParams/Select.php b/application/Espo/ORM/QueryParams/Select.php new file mode 100644 index 0000000000..f542471b51 --- /dev/null +++ b/application/Espo/ORM/QueryParams/Select.php @@ -0,0 +1,65 @@ +params['sth'] ?? false; + } + + /** + * Get select items. + */ + public function getSelect() : array + { + return $this->params['select'] ?? []; + } + + protected function validateRawParams(array $params) + { + $this->validateRawParamsSelecting($params); + } +} diff --git a/application/Espo/ORM/QueryParams/SelectBuilder.php b/application/Espo/ORM/QueryParams/SelectBuilder.php new file mode 100644 index 0000000000..94885f2503 --- /dev/null +++ b/application/Espo/ORM/QueryParams/SelectBuilder.php @@ -0,0 +1,150 @@ +params); + } + + /** + * Clone an existing query for a subsequent modifying and building. + */ + public function clone(Select $query) : self + { + $this->cloneInternal($query); + + return $this; + } + + /** + * Set to return STH collection. Recommended for fetching large number of records. + * + * @todo Remove. + */ + public function sth() : self + { + $this->params['returnSthCollection'] = true; + + return $this; + } + + /** + * Apply OFFSET and LIMIT. + */ + public function limit(?int $offset = null, ?int $limit = null) : self + { + $this->params['offset'] = $offset; + $this->params['limit'] = $limit; + + return $this; + } + + /** + * Specify SELECT. Columns and expressions to be selected. If not called, then all entity attributes will be selected. + * Passing an array will reset previously set items. + * Passing a string will append an item. + * + * Usage options: + * * `select([$item1, $item2, ...])` + * * `select(string $expression)` + * * `select(string $expression, string $alias)` + * + * @param array|string $select + */ + public function select($select, ?string $alias = null) : self + { + if (is_array($select)) { + $this->params['select'] = $select; + + return $this; + } + + if (is_string($select)) { + $this->params['select'] = $this->params['select'] ?? []; + + if ($alias) { + $this->params['select'][] = [$select, $alias]; + } else { + $this->params['select'][] = $select; + } + + return $this; + } + + throw new InvalidArgumentException(); + } + + /** + * Specify GROUP BY. + */ + public function groupBy(array $groupBy) : self + { + $this->params['groupBy'] = $groupBy; + + return $this; + } + + /** + * Add a HAVING clause. + * + * Two usage options: + * * `having(array $havingClause)` + * * `having(string $key, string $value)` + * + * @param array|string $keyOrClause + * @param ?array|string $value + */ + public function having($keyOrClause = [], $value = null) : self + { + $this->applyWhereClause('havingClause', $keyOrClause, $value); + + return $this; + } + + /** + * @todo Remove? + */ + public function withDeleted() : self + { + $this->params['withDeleted'] = true; + + return $this; + } +} diff --git a/application/Espo/ORM/QueryParams/SelectingBuilderTrait.php b/application/Espo/ORM/QueryParams/SelectingBuilderTrait.php new file mode 100644 index 0000000000..5be924050b --- /dev/null +++ b/application/Espo/ORM/QueryParams/SelectingBuilderTrait.php @@ -0,0 +1,208 @@ +params['from'])) { + throw new LogicException("Method 'from' can be called only once."); + } + + $this->params['from'] = $entityType; + + return $this; + } + + /** + * Set DISTINCT parameter. + */ + public function distinct() : self + { + $this->params['distinct'] = true; + + return $this; + } + + /** + * Add a WHERE clause. + * + * Two usage options: + * * `where(array $whereClause)` + * * `where(string $key, string $value)` + * + * @param array|string $keyOrClause A key or where clause. + * @param ?array|string $value A value. If the first argument is an array, then should be omited. + */ + public function where($keyOrClause = [], $value = null) : self + { + $this->applyWhereClause('whereClause', $keyOrClause, $value); + + return $this; + } + + protected function applyWhereClause(string $type, $keyOrClause, $value) + { + $this->params[$type] = $this->params[$type] ?? []; + + $original = $this->params[$type]; + + if (!is_string($keyOrClause) && !is_array($keyOrClause)) { + throw new InvalidArgumentException(); + } + + if (is_array($keyOrClause)) { + $new = $keyOrClause; + } + + if (is_string($keyOrClause)) { + $new = [$keyOrClause => $value]; + } + + $containsSameKeys = (bool) count( + array_intersect( + array_keys($new), + array_keys($original) + ) + ); + + if ($containsSameKeys) { + $this->params[$type][] = $new; + + return $this; + } + + $this->params[$type] = $new + $original; + + return $this; + } + + /** + * Apply ORDER. + * + * @param string|array $orderBy An attribute to order by or order definitions as an array. + * @param bool|string $direction 'ASC' or 'DESC'. TRUE for DESC order. + * If the first argument is an array then should be omitied. + */ + public function order($orderBy, $direction = Select::ORDER_ASC) : self + { + if (!$orderBy) { + throw InvalidArgumentException(); + } + + $this->params['orderBy'] = $orderBy; + $this->params['order'] = $direction; + + return $this; + } + + /** + * Add JOIN. + * + * @param string $relationName A relationName or table. A relationName is in camelCase, a table is in CamelCase. + * + */ + public function join($relationName, ?string $alias = null, ?array $conditions = null) : self + { + if (empty($this->params['joins'])) { + $this->params['joins'] = []; + } + + if (is_array($relationName)) { + $joinList = $relationName; + + foreach ($joinList as $item) { + $this->params['joins'][] = $item; + } + + return $this; + } + + if (is_null($alias) && is_null($conditions)) { + $this->params['joins'][] = $relationName; + + return $this; + } + + if (is_null($conditions)) { + $this->params['joins'][] = [$relationName, $alias]; + + return $this; + } + + $this->params['joins'][] = [$relationName, $alias, $conditions]; + + return $this; + } + + /** + * Add LEFT JOIN. + * + * @param string $relationName A relationName or table. A relationName is in camelCase, a table is in CamelCase. + */ + public function leftJoin($relationName, ?string $alias = null, ?array $conditions = null) : self + { + if (empty($this->params['leftJoins'])) { + $this->params['leftJoins'] = []; + } + + if (is_array($relationName)) { + $joinList = $relationName; + + foreach ($joinList as $item) { + $this->params['leftJoins'][] = $item; + } + + return $this; + } + + if (is_null($alias) && is_null($conditions)) { + $this->params['leftJoins'][] = $relationName; + + return $this; + } + + if (is_null($conditions)) { + $this->params['leftJoins'][] = [$relationName, $alias]; + + return $this; + } + + $this->params['leftJoins'][] = [$relationName, $alias, $conditions]; + + return $this; + } +} diff --git a/application/Espo/ORM/Repositories/Findable.php b/application/Espo/ORM/QueryParams/SelectingTrait.php similarity index 75% rename from application/Espo/ORM/Repositories/Findable.php rename to application/Espo/ORM/QueryParams/SelectingTrait.php index 98a9b839b8..c0fc52783c 100644 --- a/application/Espo/ORM/Repositories/Findable.php +++ b/application/Espo/ORM/QueryParams/SelectingTrait.php @@ -27,27 +27,26 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM\Repositories; +namespace Espo\ORM\QueryParams; -use Espo\ORM\{ - Entity, - Collection, -}; +use RuntimeException; -interface Findable +trait SelectingTrait { /** - * A number of records matching specific parameters. + * Get an entity type. */ - public function count(array $params) : int; + public function getFrom() : string + { + return $this->params['from']; + } - /** - * Find records matching specific parameters. - */ - public function find(array $params) : Collection; + protected static function validateRawParamsSelecting(array $params) + { + $from = $params['from'] ?? null; - /** - * Find the first record matching specific parameters. - */ - public function findOne(array $params) : ?Entity; + if (!$from || !is_string($from)) { + throw new RuntimeException("Select params: Missing 'from'."); + } + } } diff --git a/application/Espo/ORM/QueryParams/Update.php b/application/Espo/ORM/QueryParams/Update.php new file mode 100644 index 0000000000..330cbd8a6f --- /dev/null +++ b/application/Espo/ORM/QueryParams/Update.php @@ -0,0 +1,52 @@ +validateRawParamsSelecting($params); + + $set = $params['set'] ?? null; + + if (!$set || !is_array($set)) { + throw new RuntimeException("Update params: Bad or missing 'set' parameter."); + } + } +} diff --git a/application/Espo/ORM/Sth2Collection.php b/application/Espo/ORM/QueryParams/UpdateBuilder.php similarity index 66% rename from application/Espo/ORM/Sth2Collection.php rename to application/Espo/ORM/QueryParams/UpdateBuilder.php index 81dcee7c22..554b4b203c 100644 --- a/application/Espo/ORM/Sth2Collection.php +++ b/application/Espo/ORM/QueryParams/UpdateBuilder.php @@ -27,37 +27,46 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM; +namespace Espo\ORM\QueryParams; -use Espo\ORM\DB\Query\BaseQuery as Query; +use LogicException; -use PDO; - -class Sth2Collection extends SthCollection +class UpdateBuilder implements Builder { - public function __construct( - string $entityType, EntityFactory $entityFactory, Query $query, PDO $pdo, array $selectParams = [] - ) { - $this->selectParams = $selectParams; - $this->entityType = $entityType; + use SelectingBuilderTrait; - $this->entityFactory = $entityFactory; - $this->query = $query; - $this->pdo = $pdo; + /** + * Build a UPDATE query. + */ + public function build() : Update + { + return Update::fromRaw($this->params); } - protected function getQuery() + /** + * Clone an existing query for a subsequent modifying and building. + */ + public function clone(Delete $query) : self { - return $this->query; + $this->cloneInternal($query); + + return $this; } - protected function getPdo() + public function set(array $set) : self { - return $this->pdo; + $this->params['set'] = $set; + + return $this; } - protected function getEntityFactory() + /** + * Apply LIMIT. + */ + public function limit(?int $limit = null) : self { - return $this->entityFactory; + $this->params['limit'] = $limit; + + return $this; } } diff --git a/application/Espo/ORM/RDBSelectBuilder.php b/application/Espo/ORM/RDBSelectBuilder.php deleted file mode 100644 index f9664474e4..0000000000 --- a/application/Espo/ORM/RDBSelectBuilder.php +++ /dev/null @@ -1,374 +0,0 @@ -entityManager = $entityManager; - } - - public function from(string $entityType) : self - { - if ($this->repository) { - throw new Error("SelectBuilder: Method 'from' can be called only once."); - } - - $this->entityType = $entityType; - $this->repository = $this->entityManager->getRepository($entityType); - - return $this; - } - - protected function isExecutable() : bool - { - return (bool) $this->entityType; - } - - protected function processExecutableCheck() - { - if (!$this->isExecutable()) { - throw new Error("SelectBuilder: Method 'from' must be called."); - } - } - - public function find(array $params = []) : Collection - { - $this->processExecutableCheck(); - - $params = $this->getMergedParams($params); - return $this->repository->find($params); - } - - public function findOne(array $params = []) : ?Entity - { - $this->processExecutableCheck(); - - $params = $this->getMergedParams($params); - return $this->repository->findOne($params); - } - - public function count(array $params = []) : int - { - $this->processExecutableCheck(); - - $params = $this->getMergedParams($params); - return $this->repository->count($params); - } - - public function max(string $attribute) - { - $this->processExecutableCheck(); - - $params = $this->getMergedParams(); - return $this->repository->max($attribute, $params); - } - - public function min(string $attribute) - { - $this->processExecutableCheck(); - - $params = $this->getMergedParams(); - return $this->repository->min($attribute, $params); - } - - public function sum(string $attribute) - { - $this->processExecutableCheck(); - - $params = $this->getMergedParams(); - return $this->repository->sum($attribute, $params); - } - - /** - * Add JOIN. - * - * @param string|array $relationName A relationName or table. A relationName is in camelCase, a table is in CamelCase. - * - * Usage options: - * * `join(string $relationName)` - * * `join(array $joinDefinitionList)` - * - * Usage examples: - * ``` - * ->join($relationName) - * ->join($relationName, $alias, $conditions) - * ->join([$relationName1, $relationName2, ...]) - * ->join([[$relationName, $alias], ...]) - * ->join([[$relationName, $alias, $conditions], ...]) - * ``` - */ - public function join($relationName, ?string $alias = null, ?array $conditions = null) : self - { - if (empty($this->params['joins'])) { - $this->params['joins'] = []; - } - - if (is_array($relationName)) { - $joinList = $relationName; - foreach ($joinList as $item) { - $this->params['joins'][] = $item; - } - return $this; - } - - if (is_null($alias) && is_null($conditions)) { - $this->params['joins'][] = $relationName; - return $this; - } - - if (is_null($conditions)) { - $this->params['joins'][] = [$relationName, $alias]; - return $this; - } - - $this->params['joins'][] = [$relationName, $alias, $conditions]; - return $this; - } - - /** - * Add LEFT JOIN. - * - * @param string|array $relationName A relationName or table. A relationName is in camelCase, a table is in CamelCase. - * - * This method works the same way as `join` method. - */ - public function leftJoin($relationName, ?string $alias = null, ?array $conditions = null) : self - { - if (empty($this->params['leftJoins'])) { - $this->params['leftJoins'] = []; - } - - if (is_array($relationName)) { - $joinList = $relationName; - foreach ($joinList as $item) { - $this->params['leftJoins'][] = $item; - } - return $this; - } - - if (is_null($alias) && is_null($conditions)) { - $this->params['leftJoins'][] = $relationName; - return $this; - } - - if (is_null($conditions)) { - $this->params['leftJoins'][] = [$relationName, $alias]; - return $this; - } - - $this->params['leftJoins'][] = [$relationName, $alias, $conditions]; - return $this; - } - - /** - * Set DISTINCT parameter. - */ - public function distinct() : self - { - $this->params['distinct'] = true; - - return $this; - } - - /** - * Set to return STH collection. Recommended fetching large number of records. - */ - public function sth() : self - { - $this->params['returnSthCollection'] = true; - - return $this; - } - - /** - * Add a WHERE clause. - * - * Two usage options: - * * `where(array $whereClause)` - * * `where(string $key, string $value)` - */ - public function where($param1 = [], $param2 = null) : self - { - if (is_array($param1)) { - $this->whereClause = $param1 + $this->whereClause; - - } else { - if (!is_null($param2)) { - $this->whereClause[] = [$param1 => $param2]; - } - } - - return $this; - } - - /** - * Add a HAVING clause. - * - * Two usage options: - * * `having(array $havingClause)` - * * `having(string $key, string $value)` - */ - public function having($param1 = [], $param2 = null) : self - { - if (is_array($param1)) { - $this->havingClause = $param1 + $this->havingClause; - } else { - if (!is_null($param2)) { - $this->havingClause[] = [$param1 => $param2]; - } - } - - return $this; - } - - /** - * Apply ORDER. - * - * @param string|array $attribute An attribute to order by or order definitions as an array. - * @param bool|string $direction TRUE for DESC order. - */ - public function order($attribute = 'id', $direction = 'ASC') : self - { - $this->params['orderBy'] = $attribute; - $this->params['order'] = $direction; - - return $this; - } - - /** - * Apply OFFSET and LIMIT. - */ - public function limit(?int $offset = null, ?int $limit = null) : self - { - $this->params['offset'] = $offset; - $this->params['limit'] = $limit; - - return $this; - } - - /** - * Specify SELECT. Which attributes to select. All attributes are selected by default. - */ - public function select(array $select) : self - { - $this->params['select'] = $select; - - return $this; - } - - /** - * Specify GROUP BY. - */ - public function groupBy(array $groupBy) : self - { - $this->params['groupBy'] = $groupBy; - - return $this; - } - - /** - * Builds result select parameters. - */ - public function build() : RDBSelect - { - $this->processExecutableCheck(); - - return new RDBSelect($this->entityType, $this->getMergedParams()); - } - - protected function getMergedParams(array $params = []) : array - { - if (isset($params['whereClause'])) { - $params['whereClause'] = $params['whereClause']; - if (!empty($this->whereClause)) { - $params['whereClause'][] = $this->whereClause; - } - } else { - $params['whereClause'] = $this->whereClause; - } - if (!empty($params['havingClause'])) { - $params['havingClause'] = $params['havingClause']; - if (!empty($this->havingClause)) { - $params['havingClause'][] = $this->havingClause; - } - } else { - $params['havingClause'] = $this->havingClause; - } - - if (empty($params['whereClause'])) { - unset($params['whereClause']); - } - - if (empty($params['havingClause'])) { - unset($params['havingClause']); - } - - if (!empty($params['leftJoins']) && !empty($this->params['leftJoins'])) { - foreach ($this->params['leftJoins'] as $j) { - $params['leftJoins'][] = $j; - } - } - - if (!empty($params['joins']) && !empty($this->params['joins'])) { - foreach ($this->params['joins'] as $j) { - $params['joins'][] = $j; - } - } - - $params = array_replace_recursive($this->params, $params); - - return $params; - } -} diff --git a/application/Espo/ORM/Repository/EmptyHookMediator.php b/application/Espo/ORM/Repository/EmptyHookMediator.php new file mode 100644 index 0000000000..e48732ffad --- /dev/null +++ b/application/Espo/ORM/Repository/EmptyHookMediator.php @@ -0,0 +1,68 @@ +entityManager = $entityManager; + $this->entity = $entity; + $this->hookMediator = $hookMediator; + + if (!$entity->get('id')) { + throw new RuntimeException("Can't use an entity w/o ID."); + } + + if (!$entity->hasRelation($relationName)) { + throw new RuntimeException("Entity does not have a relation '{$relationName}'."); + } + + $this->relationName = $relationName; + + $this->relationType = $entity->getRelationType($relationName); + + $this->foreignEntityType = $entity->getRelationParam($relationName, 'entity'); + + $this->entityType = $entity->getEntityType(); + + if ($this->isBelongsToParentType()) { + $this->noBuilder = true; + } + } + + protected function createBuilder(?Select $query = null) : RDBRelationSelectBuilder + { + if ($this->noBuilder) { + throw new RuntimeException("Can't use query builder for the '{$this->relationType}' relation type."); + } + + return new RDBRelationSelectBuilder($this->entityManager, $this->entity, $this->relationName, $query); + } + + /** + * Clone a query. + */ + public function clone(Select $query) : RDBRelationSelectBuilder + { + if ($this->noBuilder) { + throw new RuntimeException("Can't use clone for the '{$this->relationType}' relation type."); + } + + if ($query->getFrom() !== $this->foreignEntityType) { + throw new RuntimeException("Passed query doesn't match the entity type."); + } + + return $this->createBuilder($query); + } + + protected function isBelongsToParentType() : bool + { + return $this->relationType === Entity::BELONGS_TO_PARENT; + } + + protected function getMapper() : Mapper + { + return $this->entityManager->getMapper(); + } + + /** + * Find related records. + */ + public function find() : Collection + { + if ($this->isBelongsToParentType()) { + $collection = $this->entityManager->getCollectionFactory()->create(); + + $entity = $this->getMapper()->selectRelated($this->entity, $this->relationName); + + if ($entity) { + $collection[] = $entity; + } + + $collection->setAsFetched(); + + return $collection; + } + + return $this->createBuilder()->find(); + } + + /** + * Find a first record. + */ + public function findOne() : ?Entity + { + if ($this->isBelongsToParentType()) { + return $this->getMapper()->selectRelated($this->entity, $this->relationName); + } + + $collection = $this->limit(0, 1)->find(); + + if (!count($collection)) { + return null; + } + + foreach ($collection as $entity) { + return $entity; + } + } + + /** + * Get a number of related records. + */ + public function count() : int + { + return $this->createBuilder()->count(); + } + + /** + * Add JOIN. + * + * @see Espo\ORM\QueryParams\SelectBuilder::join() + */ + public function join(string $relationName, ?string $alias = null, ?array $conditions = null) : RDBRelationSelectBuilder + { + return $this->createBuilder()->join($relationName, $alias, $conditions); + } + + /** + * Add LEFT JOIN. + * + * @see Espo\ORM\QueryParams\SelectBuilder::leftJoin() + */ + public function leftJoin(string $relationName, ?string $alias = null, ?array $conditions = null) : RDBRelationSelectBuilder + { + return $this->createBuilder()->leftJoin($relationName, $alias, $conditions); + } + + /** + * Set DISTINCT parameter. + */ + public function distinct() : RDBRelationSelectBuilder + { + return $this->createBuilder()->distinct(); + } + + /** + * Set to return STH collection. Recommended for fetching large number of records. + * + * @todo Remove. + */ + public function sth() : RDBRelationSelectBuilder + { + return $this->createBuilder()->sth(); + } + + /** + * Add a WHERE clause. + * + * @see Espo\ORM\QueryParams\SelectBuilder::where() + * + * @param array|string $keyOrClause + * @param ?array|string $value + */ + public function where($keyOrClause = [], $value = null) : RDBRelationSelectBuilder + { + return $this->createBuilder()->where($keyOrClause, $value); + } + + /** + * Add a HAVING clause. + * + * @see Espo\ORM\QueryParams\SelectBuilder::having() + * + * @param array|string $keyOrClause + * @param ?array|string $value + */ + public function having($keyOrClause = [], $value = null) : RDBRelationSelectBuilder + { + return $this->createBuilder()->having($keyOrClause, $params2); + } + + /** + * Apply ORDER. + * + * @see Espo\ORM\QueryParams\SelectBuilder::order() + * + * @param string|array $orderBy + * @param bool|string $direction + */ + public function order($orderBy, $direction = 'ASC') : RDBRelationSelectBuilder + { + return $this->createBuilder()->order($orderBy, $direction); + } + + /** + * Apply OFFSET and LIMIT. + */ + public function limit(?int $offset = null, ?int $limit = null) : RDBRelationSelectBuilder + { + return $this->createBuilder()->limit($offset, $limit); + } + + /** + * Specify SELECT. Which attributes to select. All attributes are selected by default. + * + * @see Espo\ORM\QueryParams\SelectBuilder::select() + * + * @param array|string $select + */ + public function select($select, ?string $alias = null) : RDBRelationSelectBuilder + { + return $this->createBuilder()->select($select, $alias); + } + + /** + * Specify GROUP BY. + */ + public function groupBy(array $groupBy) : RDBRelationSelectBuilder + { + return $this->createBuilder()->groupBy($groupBy); + } + + /** + * @deprecated Use `->select('linkNameMiddle', 'attributeName')` instead. + */ + public function columns(array $columns) : RDBRelationSelectBuilder + { + return $this->createBuilder()->columns($columns); + } + + /** + * Apply middle table conditions for a many-to-many relationship. + * + * @see Espo\ORM\Repository\RDBRelationSelectBuilder::columnsWhere() + */ + public function columnsWhere(array $where) : RDBRelationSelectBuilder + { + return $this->createBuilder()->columnsWhere($where); + } + + protected function processCheckForeignEntity(Entity $entity) + { + if ($this->foreignEntityType && $this->foreignEntityType !== $entity->getEntityType()) { + throw new InvalidArgumentException("Entity type doesn't match an entity type of the relation."); + } + + if (!$entity->id) { + throw new RuntimeException("Can't use an entity w/o ID."); + } + } + + public function isRelated(Entity $entity) : bool + { + if (!$entity->id) { + throw new RuntimeException("Can't use an entity w/o ID."); + } + + if ($this->isBelongsToParentType()) { + return $this->isRelatedBelongsToParent($entity); + } + + $this->processCheckForeignEntity($entity); + + return (bool) $this->createBuilder() + ->select(['id']) + ->where(['id' => $entity->id]) + ->findOne(); + } + + protected function isRelatedBelongsToParent(Entity $entity) : bool + { + $fromEntity = $this->entity; + + $idAttribute = $this->relationName . 'Id'; + $typeAttribute = $this->relationName . 'Type'; + + if (!$fromEntity->has($idAttribute) || !$fromEntity->has($typeAttribute)) { + $fromEntity = $this->entityManager->getEntity($fromEntity->getEntityType(), $fromEntity->id); + } + + if (!$fromEntity) { + return false; + } + + return + $fromEntity->get($idAttribute) === $entity->id + && + $fromEntity->get($typeAttribute) === $entity->getEntityType(); + } + + /** + * Relate with an entity. + */ + public function relate(Entity $entity, ?array $columnData = null, array $options = []) + { + $this->processCheckForeignEntity($entity); + + $this->beforeRelate($entity, $columnData, $options); + + $result = $this->getMapper()->relateById($this->entity, $this->relationName, $entity->id, $columnData); + + if (!$result) { + return; + } + + $this->afterRelate($entity, $columnData, $options); + } + + /** + * Unrelate from an entity. + */ + public function unrelate(Entity $entity, array $options = []) + { + $this->processCheckForeignEntity($entity); + + $this->beforeUnrelate($entity, $options); + + $result = $this->getMapper()->unrelateById($this->entity, $this->relationName, $entity->id); + + if (!$result) { + return; + } + + $this->afterUnrelate($entity, $options); + } + + public function massRelate(Select $query, array $options = []) + { + $this->beforeMassRelate($query, $options); + + $this->getMapper()->massRelate($this->entity, $this->relationName, $query); + + $this->afterMassRelate($query, $options); + } + + /** + * Update relationship columns. For many-to-many relationships. + */ + public function updateColumns(Entity $entity, array $columnData) + { + $this->processCheckForeignEntity($entity); + + if ($this->relationType !== Entity::MANY_MANY) { + throw new RuntimeException("Can't update not many-to-many relation."); + } + + $this->getMapper()->updateRelationColumns($this->entity, $this->relationName, $entity->id, $columnData); + } + + /** + * Get a relationship column value. For many-to-many relationships. + * + * @return mixed + */ + public function getColumn(Entity $entity, string $column) + { + $this->processCheckForeignEntity($entity); + + if ($this->relationType !== Entity::MANY_MANY) { + throw new RuntimeException("Can't get a column of not many-to-many relation."); + } + + return $this->getMapper()->getRelationColumn($this->entity, $this->relationName, $entity->id, $column); + } + + protected function beforeRelate(Entity $entity, ?array $columnData, array $options) + { + $this->hookMediator->beforeRelate($this->entity, $this->relationName, $entity, $columnData, $options); + } + + protected function afterRelate(Entity $entity, ?array $columnData, array $options) + { + $this->hookMediator->afterRelate($this->entity, $this->relationName, $entity, $columnData, $options); + } + + protected function beforeUnrelate(Entity $entity, array $options) + { + $this->hookMediator->beforeUnrelate($this->entity, $this->relationName, $entity, $options); + } + + protected function afterUnrelate(Entity $entity, array $options) + { + $this->hookMediator->afterUnrelate($this->entity, $this->relationName, $entity, $options); + } + + protected function beforeMassRelate(Select $query, array $options) + { + $this->hookMediator->beforeMassRelate($this->entity, $this->relationName, $query, $options); + } + + protected function afterMassRelate(Select $query, array $options) + { + $this->hookMediator->afterMassRelate($this->entity, $this->relationName, $query, $options); + } +} diff --git a/application/Espo/ORM/Repository/RDBRelationSelectBuilder.php b/application/Espo/ORM/Repository/RDBRelationSelectBuilder.php new file mode 100644 index 0000000000..08bcc302ce --- /dev/null +++ b/application/Espo/ORM/Repository/RDBRelationSelectBuilder.php @@ -0,0 +1,376 @@ +entityManager = $entityManager; + + $this->entity = $entity; + + $this->relationName = $relationName; + + $this->relationType = $entity->getRelationType($relationName); + + $this->entityType = $entity->getEntityType(); + + $this->foreignEntityType = $entity->getRelationParam($relationName, 'entity'); + + if ($query) { + $this->builder = $this->createSelectBuilder()->clone($query); + } else { + $this->builder = $this->createSelectBuilder()->from($this->foreignEntityType); + } + } + + protected function createSelectBuilder() : SelectBuilder + { + return new SelectBuilder($this->entityManager->getQueryComposer()); + } + + protected function getMapper() : Mapper + { + return $this->entityManager->getMapper(); + } + + /** + * Additional middle table columns. Only for many-to-many relationships. + * + * Usage example: + * `['columnName' => 'attributeName']` + * Where `attributeName` is a non storable attribute that will be set with a column value. + * + * @todo Remove? Use attribute definitions to detect a proper select expression (in QueryComposer). + * @deprecated Use `->select('middleTable', 'attributeName')` instead. + */ + public function columns(array $columns) : self + { + if (!count($columns)) { + return $this; + } + + if ($this->relationType !== Entity::MANY_MANY) { + throw new RuntimeException("Can't select relation columns for not many-to-many relationship."); + } + + $middleName = lcfirst( + $this->entity->getRelationParam($this->relationName, 'relationName') + ); + + foreach ($columns as $column => $alias) { + $this->additionalSelect[] = [ + $middleName . '.' . $column, + $alias, + ]; + } + + return $this; + } + + /** + * Apply middle table conditions for a many-to-many relationship. + * + * Usage example: + * `->columnsWhere(['column' => $value])` + */ + public function columnsWhere(array $where) : self + { + if ($this->relationType !== Entity::MANY_MANY) { + throw new RuntimeException("Can't add columns where for not many-to-many relationship."); + } + + $transformedWhere = $this->applyMiddleAliasToWhere($where); + + $this->where($transformedWhere); + + return $this; + } + + protected function applyMiddleAliasToWhere(array $where) : array + { + $transformedWhere = []; + + $middleName = lcfirst( + $this->entity->getRelationParam($this->relationName, 'relationName') + ); + + foreach ($where as $key => $value) { + $transformedKey = $key; + $transformedValue = $value; + + if (is_int($key)) { + $transformedKey = $key; + } + + if ( + is_string($key) && + strlen($key) && + strpos($key, '.') === false && + $key[0] === strtolower($key[0]) + ) { + $transformedKey = $middleName . '.' . $key; + } + + if (is_array($value)) { + $transformedValue = $this->applyMiddleAliasToWhere($value); + } + + $transformedWhere[$transformedKey] = $transformedValue; + } + + return $transformedWhere; + } + + protected function addAdditionalSelect() + { + if (!count($this->additionalSelect)) { + return; + } + + $select = $this->builder->build()->getSelect(); + + if (!count($select)) { + $this->builder->select('*'); + } + + foreach ($this->additionalSelect as $item) { + $this->builder->select($item[0], $item[1]); + } + } + + /** + * Find related records by a criteria. + */ + public function find() : Collection + { + $this->addAdditionalSelect(); + + $query = $this->builder->build(); + + $related = $this->getMapper()->selectRelated($this->entity, $this->relationName, $query); + + if ($related instanceof Collection) { + return $related; + } + + $collection = $this->entityManager->getCollectionFactory()->create($this->foreignEntityType); + $collection->setAsFetched(); + + if ($related instanceof Entity) { + $collection[] = $related; + } + + return $collection; + } + + /** + * Find a first related records by a criteria. + */ + public function findOne() : ?Entity + { + $collection = $this->limit(0, 1)->find(); + + if (!count($collection)) { + return null; + } + + foreach ($collection as $entity) { + return $entity; + } + } + + /** + * Get a number of related records that meet criteria. + */ + public function count() : int + { + $query = $this->builder->build(); + + return $this->getMapper()->countRelated($this->entity, $this->relationName, $query); + } + + + /** + * Add JOIN. + * + * @see Espo\ORM\QueryParams\SelectBuilder::join() + */ + public function join($relationName, ?string $alias = null, ?array $conditions = null) : self + { + $this->builder->join($relationName, $alias, $conditions); + + return $this; + } + + /** + * Add LEFT JOIN. + * + * @see Espo\ORM\QueryParams\SelectBuilder::leftJoin() + */ + public function leftJoin($relationName, ?string $alias = null, ?array $conditions = null) : self + { + $this->builder->leftJoin($relationName, $alias, $conditions); + + return $this; + } + + /** + * Set DISTINCT parameter. + */ + public function distinct() : self + { + $this->builder->distinct(); + + return $this; + } + + /** + * Set to return STH collection. Recommended for fetching large number of records. + * + * @todo Remove. + */ + public function sth() : self + { + $this->builder->sth(); + + return $this; + } + + /** + * Add a WHERE clause. + * + * @see Espo\ORM\QueryParams\SelectBuilder::where() + * + * @param array|string $keyOrClause + * @param ?array|string $value + */ + public function where($keyOrClause = [], $value = null) : self + { + $this->builder->where($keyOrClause, $value); + + return $this; + } + + /** + * Add a HAVING clause. + * + * @see Espo\ORM\QueryParams\SelectBuilder::having() + */ + public function having($keyOrClause = [], $value = null) : self + { + $this->builder->having($keyOrClause, $params2); + + return $this; + } + + /** + * Apply ORDER. + * + * @see Espo\ORM\QueryParams\SelectBuilder::order() + * + * @param string|array $orderBy + * @param bool|string $direction + */ + public function order($orderBy, $direction = 'ASC') : self + { + $this->builder->order($orderBy, $direction); + + return $this; + } + + /** + * Apply OFFSET and LIMIT. + */ + public function limit(?int $offset = null, ?int $limit = null) : self + { + $this->builder->limit($offset, $limit); + + return $this; + } + + /** + * Specify SELECT. Which attributes to select. All attributes are selected by default. + * + * @see Espo\ORM\QueryParams\SelectBuilder::select() + * + * @param array|string $select + */ + public function select($select, ?string $alias = null) : self + { + $this->builder->select($select, $alias); + + return $this; + } + + /** + * Specify GROUP BY. + */ + public function groupBy(array $groupBy) : self + { + $this->builder->groupBy($groupBy); + + return $this; + } +} diff --git a/application/Espo/ORM/Repositories/RDB.php b/application/Espo/ORM/Repository/RDBRepository.php similarity index 59% rename from application/Espo/ORM/Repositories/RDB.php rename to application/Espo/ORM/Repository/RDBRepository.php index 90930986dd..2a04bdc57a 100644 --- a/application/Espo/ORM/Repositories/RDB.php +++ b/application/Espo/ORM/Repository/RDBRepository.php @@ -27,48 +27,51 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM\Repositories; +namespace Espo\ORM\Repository; use Espo\ORM\{ EntityManager, EntityFactory, Collection, Entity, - Repository, - DB\Mapper, - RDBSelectBuilder as RDBSelectBuilder, + Mapper\Mapper, + QueryParams\Select, }; use StdClass; use RuntimeException; +use InvalidArgumentException; -class RDB extends Repository implements Findable, Relatable, Removable +class RDBRepository extends Repository { protected $mapper; private $isTableLocked = false; - public function __construct(string $entityType, EntityManager $entityManager, EntityFactory $entityFactory) - { + protected $hookMediator; + + public function __construct( + string $entityType, EntityManager $entityManager, EntityFactory $entityFactory, ?HookMediator $hookMediator = null + ) { $this->entityType = $entityType; $this->entityName = $entityType; $this->entityFactory = $entityFactory; - $this->seed = $this->entityFactory->create($entityType); - $this->entityClassName = get_class($this->seed); $this->entityManager = $entityManager; + + $this->seed = $this->entityFactory->create($entityType); + + $this->hookMediator = $hookMediator ?? (new EmptyHookMediator()); } protected function getMapper() : Mapper { - if (empty($this->mapper)) { - $this->mapper = $this->getEntityManager()->getMapper('RDB'); - } - return $this->mapper; + return $this->entityManager->getMapper(); } /** * @deprecated + * @todo Remove in 6.0. */ public function handleSelectParams(&$params) { @@ -84,6 +87,7 @@ class RDB extends Repository implements Findable, Relatable, Removable if ($entity) { $entity->setIsNew(true); $entity->populateDefaults(); + return $entity; } @@ -93,16 +97,17 @@ class RDB extends Repository implements Findable, Relatable, Removable /** * Fetch an entity by ID. */ - public function getById(string $id, array $params = []) : ?Entity + public function getById(string $id) : ?Entity { - $entity = $this->entityFactory->create($this->entityType); - if (!$entity) return null; + $select = $this->entityManager->getQueryBuilder() + ->select() + ->from($this->entityType) + ->where([ + 'id' => $id, + ]) + ->build(); - if (empty($params['skipAdditionalSelectParams'])) { - $this->handleSelectParams($params); - } - - return $this->getMapper()->selectById($entity, $id, $params); + return $this->getMapper()->selectOne($select); } public function get(?string $id = null) : ?Entity @@ -110,19 +115,21 @@ class RDB extends Repository implements Findable, Relatable, Removable if (is_null($id)) { return $this->getNew(); } + return $this->getById($id); } - protected function beforeSave(Entity $entity, array $options = []) - { - } - - protected function afterSave(Entity $entity, array $options = []) + protected function processCheckEntity(Entity $entity) { + if ($entity->getEntityType() !== $this->entityType) { + throw new RuntimeException("An entity type doesn't match the repository."); + } } public function save(Entity $entity, array $options = []) { + $this->processCheckEntity($entity); + $entity->setAsBeingSaved(); if (empty($options['skipBeforeSave']) && empty($options['skipAll'])) { @@ -161,117 +168,252 @@ class RDB extends Repository implements Findable, Relatable, Removable return $this->getMapper()->restoreDeleted($this->entityType, $id); } - protected function beforeRemove(Entity $entity, array $options = []) - { - } - - protected function afterRemove(Entity $entity, array $options = []) + /** + * Get an access point for a specific relation of a record. + */ + public function getRelation(Entity $entity, string $relationName) : RDBRelation { + return new RDBRelation($this->entityManager, $entity, $relationName, $this->hookMediator); } + /** + * Remove a record (mark as deleted). + */ public function remove(Entity $entity, array $options = []) { + $this->processCheckEntity($entity); + $this->beforeRemove($entity, $options); + $this->getMapper()->delete($entity); + $this->afterRemove($entity, $options); } + /** + * @deprecated Use QueryBuilder instead. + */ public function deleteFromDb(string $id, bool $onlyDeleted = false) { $this->getMapper()->deleteFromDb($this->entityType, $id, $onlyDeleted); } - public function find(array $params = []) : Collection + /** + * Find records. + * + * @param $params @deprecated Omit it. + */ + public function find(?array $params = []) : Collection { + // @todo Remove. + if (empty($query['skipAdditionalSelectParams'])) { + $this->handleSelectParams($query); + } + + return $this->createSelectBuilder()->find($params); + } + + /** + * Find one record. + * + * @param $params @deprecated Omit it. + */ + public function findOne(?array $params = []) : ?Entity + { + // @todo Remove. if (empty($params['skipAdditionalSelectParams'])) { $this->handleSelectParams($params); } - $collection = $this->getMapper()->select($this->seed, $params); - - return $collection; - } - - public function findOne(array $params = []) : ?Entity - { - unset($params['returnSthCollection']); - $collection = $this->limit(0, 1)->find($params); - if (count($collection)) { - return $collection[0]; + foreach ($collection as $entity) { + return $entity; } return null; } - public function findByQuery(string $sql, ?string $collectionType = null) + /** + * Find records by a SQL query. + */ + public function findBySql(string $sql) : Collection { - if (!$collectionType) { - $collection = $this->getMapper()->selectByQuery($this->seed, $sql); - } else if ($collectionType === EntityManager::STH_COLLECTION) { - $collection = $this->getEntityManager()->createSthCollection($this->entityType); - $collection->setQuery($sql); - } - - return $collection; + return $this->getMapper()->selectBySql($this->entityType, $sql); } - public function findRelated(Entity $entity, string $relationName, array $params = []) + /** + * @deprecated + */ + public function findRelated(Entity $entity, string $relationName, ?array $params = null) { + $params = $params ?? []; + + if ($entity->getEntityType() !== $this->entityType) { + throw new InvalidArgumentException("Not supported entity type."); + } + if (!$entity->id) { return null; } - if ($entity->getRelationType($relationName) === Entity::BELONGS_TO_PARENT) { - $entityType = $entity->get($relationName . 'Type'); - } else { - $entityType = $entity->getRelationParam($relationName, 'entity'); - } + $type = $entity->getRelationType($relationName); + $entityType = $entity->getRelationParam($relationName, 'entity'); if ($entityType && empty($params['skipAdditionalSelectParams'])) { - $this->getEntityManager()->getRepository($entityType)->handleSelectParams($params); + $this->entityManager->getRepository($entityType)->handleSelectParams($params); } - $result = $this->getMapper()->selectRelated($entity, $relationName, $params); + $additionalColumns = $params['additionalColumns'] ?? []; + unset($params['additionalColumns']); + + $additionalColumnsConditions = $params['additionalColumnsConditions'] ?? []; + unset($params['additionalColumnsConditions']); + + $select = null; + + if ($entityType) { + $params['from'] = $entityType; + $select = Select::fromRaw($params); + } + + if ($type === Entity::MANY_MANY && count($additionalColumns)) { + $select = $this->applyRelationAdditionalColumns($entity, $relationName, $additionalColumns, $select); + } + + // @todo Get rid of 'additionalColumnsConditions' usage. Use 'whereClause' instead. + if ($type === Entity::MANY_MANY && count($additionalColumnsConditions)) { + $select = $this->applyRelationAdditionalColumnsConditions( + $entity, $relationName, $additionalColumnsConditions, $select + ); + } + + $result = $this->getMapper()->selectRelated($entity, $relationName, $select); return $result; } - public function countRelated(Entity $entity, string $relationName, array $params = []) : int + /** + * @deprecated + */ + public function countRelated(Entity $entity, string $relationName, ?array $params = null) : int { + $params = $params ?? []; + + if ($entity->getEntityType() !== $this->entityType) { + throw new InvalidArgumentException("Not supported entity type."); + } + if (!$entity->id) { return 0; } - $entityType = $entity->getRelationParam($relationName, 'entity'); + $type = $entity->getRelationType($relationName); + $entityType = $entity->getRelationParam($relationName, 'entity'); if ($entityType && empty($params['skipAdditionalSelectParams'])) { - $this->getEntityManager()->getRepository($entityType)->handleSelectParams($params); + $this->entityManager->getRepository($entityType)->handleSelectParams($params); } - return intval($this->getMapper()->countRelated($entity, $relationName, $params)); + $additionalColumnsConditions = $params['additionalColumnsConditions'] ?? []; + unset($params['additionalColumnsConditions']); + + if ($type === Entity::MANY_MANY && count($additionalColumnsConditions)) { + $select = $this->applyRelationAdditionalColumnsConditions($entity, $relationName, $additionalColumnsConditions, $select); + } + + $select = null; + + if ($entityType) { + $params['from'] = $entityType; + $select = Select::fromRaw($params); + } + + return (int) $this->getMapper()->countRelated($entity, $relationName, $select); } + protected function applyRelationAdditionalColumns( + Entity $entity, string $relationName, array $columns, Select $select + ) : Select { + if (empty($columns)) { + return $select; + } + + $middleName = lcfirst($entity->getRelationParam($relationName, 'relationName')); + + $selectItemList = $select->getSelect(); + + if (empty($selectItemList)) { + $selectItemList[] = '*'; + } + + foreach ($columns as $column => $alias) { + $selectItemList[] = [ + $middleName . '.' . $column, + $alias + ]; + } + + $select = $this->entityManager->getQueryBuilder() + ->clone($select) + ->select($selectItemList) + ->build(); + + return $select; + } + + protected function applyRelationAdditionalColumnsConditions( + Entity $entity, string $relationName, array $conditions, Select $select + ) : Select { + if (empty($conditions)) { + return $select; + } + + $middleName = lcfirst($entity->getRelationParam($relationName, 'relationName')); + + $builder = $this->entityManager->getQueryBuilder()->clone($select); + + foreach ($conditions as $column => $value) { + $builder->where( + $middleName . '.' . $column, + $value + ); + } + + return $builder->build(); + } + + /** + * @deprecated + */ public function isRelated(Entity $entity, string $relationName, $foreign) : bool { if (!$entity->id) { return false; } + if ($entity->getEntityType() !== $this->entityType) { + throw new InvalidArgumentException("Not supported entity type."); + } + if ($foreign instanceof Entity) { $id = $foreign->id; } else if (is_string($foreign)) { $id = $foreign; } else { + throw new RuntimeException("Bad 'foreign' value."); + } + + if (!$id) { return false; } - if (!$id) return false; - if ($entity->getRelationType($relationName) === Entity::BELONGS_TO) { $foreignEntityType = $entity->getRelationParam($relationName, 'entity'); - if (!$foreignEntityType) return false; + + if (!$foreignEntityType) { + return false; + } $foreignId = $entity->get($relationName . 'Id'); @@ -282,24 +424,33 @@ class RDB extends Repository implements Findable, Relatable, Removable } } - if (!$foreignId) return false; + if (!$foreignId) { + return false; + } - $foreignEntity = $this->getEntityManager()->getRepository($foreignEntityType)->select(['id'])->where([ - 'id' => $foreignId, - ])->findOne(); + $foreignEntity = $this->entityManager->getRepository($foreignEntityType) + ->select(['id']) + ->where(['id' => $foreignId]) + ->findOne(); - if (!$foreignEntity) return false; + if (!$foreignEntity) { + return false; + } return $foreignEntity->id === $id; } + // @todo Use related builder. return (bool) $this->countRelated($entity, $relationName, [ 'whereClause' => [ 'id' => $id, - ] + ], ]); } + /** + * @deprecated + */ public function relate(Entity $entity, string $relationName, $foreign, $columnData = null, array $options = []) { if (!$entity->id) { @@ -307,7 +458,11 @@ class RDB extends Repository implements Findable, Relatable, Removable } if (! $foreign instanceof Entity && !is_string($foreign)) { - throw new RuntimeException("Bad foreign value."); + throw new RuntimeException("Bad 'foreign' value."); + } + + if ($entity->getEntityType() !== $this->entityType) { + throw new InvalidArgumentException("Not supported entity type."); } $this->beforeRelate($entity, $relationName, $foreign, $columnData, $options); @@ -350,6 +505,9 @@ class RDB extends Repository implements Findable, Relatable, Removable return $result; } + /** + * @deprecated + */ public function unrelate(Entity $entity, string $relationName, $foreign, array $options = []) { if (!$entity->id) { @@ -360,6 +518,10 @@ class RDB extends Repository implements Findable, Relatable, Removable throw new RuntimeException("Bad foreign value."); } + if ($entity->getEntityType() !== $this->entityType) { + throw new InvalidArgumentException("Not supported entity type."); + } + $this->beforeUnrelate($entity, $relationName, $foreign, $options); $beforeMethodName = 'beforeUnrelate' . ucfirst($relationName); @@ -395,6 +557,9 @@ class RDB extends Repository implements Findable, Relatable, Removable return $result; } + /** + * @deprecated + */ public function getRelationColumn(Entity $entity, string $relationName, string $foreignId, string $column) { return $this->getMapper()->getRelationColumn($entity, $relationName, $foreignId, $column); @@ -425,7 +590,7 @@ class RDB extends Repository implements Findable, Relatable, Removable } /** - * Update relationship columns. + * @deprecated */ public function updateRelation(Entity $entity, string $relationName, $foreign, $columnData) { @@ -451,9 +616,12 @@ class RDB extends Repository implements Findable, Relatable, Removable throw new RuntimeException("Bad foreign value."); } - return $this->getMapper()->updateRelation($entity, $relationName, $id, $columnData); + return $this->getMapper()->updateRelationColumns($entity, $relationName, $id, $columnData); } + /** + * @deprecated + */ public function massRelate(Entity $entity, string $relationName, array $params = [], array $options = []) { if (!$entity->id) { @@ -462,54 +630,76 @@ class RDB extends Repository implements Findable, Relatable, Removable $this->beforeMassRelate($entity, $relationName, $params, $options); - $this->getMapper()->massRelate($entity, $relationName, $params); + $select = Select::fromRaw($params); + + $this->getMapper()->massRelate($entity, $relationName, $select); $this->afterMassRelate($entity, $relationName, $params, $options); } + /** + * @param $params @deprecated Omit it. + */ public function count(array $params = []) : int { - if (empty($params['skipAdditionalSelectParams'])) { + // @todo Remove it. + if (is_array($params) && empty($params['skipAdditionalSelectParams'])) { $this->handleSelectParams($params); } - $count = $this->getMapper()->count($this->seed, $params); - - return intval($count); + return $this->createSelectBuilder()->count($params); } - public function max(string $attribute, array $params = []) + /** + * Get a max value. + * + * @return int|float + */ + public function max(string $attribute) { - return $this->getMapper()->max($this->seed, $params, $attribute); + return $this->createSelectBuilder()->max($attribute); } - public function min(string $attribute, array $params = []) + /** + * Get a min value. + * + * @return int|float + */ + public function min(string $attribute) { - return $this->getMapper()->min($this->seed, $params, $attribute); + return $this->createSelectBuilder()->min($attribute); } - public function sum(string $attribute, array $params = []) + /** + * Get a sum value. + * + * @return int|float + */ + public function sum(string $attributel) { - return $this->getMapper()->sum($this->seed, $params, $attribute); + return $this->createSelectBuilder()->sum($attribute); + } + + /** + * Clone an existing query for a further modification and usage by 'find' or 'count' methods. + */ + public function clone(Select $query) : RDBSelectBuilder + { + if ($this->entityType !== $query->getFrom()) { + throw new RuntimeException("Can't clone a query of a different entity type."); + } + + $builder = new RDBSelectBuilder($this->entityManager, $this->entityType, $query); + + return $builder; + + //return $builder->clone($query); } /** * Add JOIN. * - * @param string|array $relationName A relationName or table. A relationName is in camelCase, a table is in CamelCase. - * - * Usage options: - * * `join(string $relationName)` - * * `join(array $joinDefinitionList)` - * - * Usage examples: - * ``` - * ->join($relationName) - * ->join($relationName, $alias, $conditions) - * ->join([$relationName1, $relationName2, ...]) - * ->join([[$relationName, $alias], ...]) - * ->join([[$relationName, $alias, $conditions], ...]) - * ``` + * @see Espo\ORM\QueryParams\SelectBuilder::join() */ public function join($relationName, ?string $alias = null, ?array $conditions = null) : RDBSelectBuilder { @@ -519,9 +709,7 @@ class RDB extends Repository implements Findable, Relatable, Removable /** * Add LEFT JOIN. * - * @param string|array $relationName A relationName or table. A relationName is in camelCase, a table is in CamelCase. - * - * This method works the same way as `join` method. + * @see Espo\ORM\QueryParams\SelectBuilder::leftJoin() */ public function leftJoin($relationName, ?string $alias = null, ?array $conditions = null) : RDBSelectBuilder { @@ -538,6 +726,8 @@ class RDB extends Repository implements Findable, Relatable, Removable /** * Set to return STH collection. Recommended fetching large number of records. + * + * @todo Remove. */ public function sth() : RDBSelectBuilder { @@ -547,9 +737,7 @@ class RDB extends Repository implements Findable, Relatable, Removable /** * Add a WHERE clause. * - * Two usage options: - * * `where(array $whereClause)` - * * `where(string $key, string $value)` + * @see Espo\ORM\QueryParams\SelectBuilder::where() */ public function where($param1 = [], $param2 = null) : RDBSelectBuilder { @@ -559,9 +747,7 @@ class RDB extends Repository implements Findable, Relatable, Removable /** * Add a HAVING clause. * - * Two usage options: - * * `having(array $havingClause)` - * * `having(string $key, string $value)` + * @see Espo\ORM\QueryParams\SelectBuilder::having() */ public function having($param1 = [], $param2 = null) : RDBSelectBuilder { @@ -589,10 +775,14 @@ class RDB extends Repository implements Findable, Relatable, Removable /** * Specify SELECT. Which attributes to select. All attributes are selected by default. + * + * @see Espo\ORM\QueryParams\SelectBuilder::select() + * + * @param array|string $select */ - public function select(array $select) : RDBSelectBuilder + public function select($select = [], ?string $alias = null) : RDBSelectBuilder { - return $this->createSelectBuilder()->select($select); + return $this->createSelectBuilder()->select($select, $alias); } /** @@ -605,19 +795,21 @@ class RDB extends Repository implements Findable, Relatable, Removable protected function getPDO() { - return $this->getEntityManager()->getPDO(); + return $this->entityManager->getPDO(); } protected function lockTable() { - $tableName = $this->getEntityManager()->getQuery()->toDb($this->entityType); + $tableName = $this->entityManager->getQueryComposer()->toDb($this->entityType); + // @todo Use Query to get SQL. Transaction query params. $this->getPDO()->query("LOCK TABLES `{$tableName}` WRITE"); $this->isTableLocked = true; } protected function unlockTable() { + // @todo Use Query to get SQL. $this->getPDO()->query("UNLOCK TABLES"); $this->isTableLocked = false; } @@ -629,8 +821,28 @@ class RDB extends Repository implements Findable, Relatable, Removable protected function createSelectBuilder() : RDBSelectBuilder { - $builder = new RDBSelectBuilder($this->getEntityManager()); - $builder->from($this->getEntityType()); + $builder = new RDBSelectBuilder($this->entityManager, $this->entityType); + return $builder; } + + protected function beforeSave(Entity $entity, array $options = []) + { + $this->hookMediator->beforeSave($entity, $options); + } + + protected function afterSave(Entity $entity, array $options = []) + { + $this->hookMediator->afterSave($entity, $options); + } + + protected function beforeRemove(Entity $entity, array $options = []) + { + $this->hookMediator->beforeRemove($entity, $options); + } + + protected function afterRemove(Entity $entity, array $options = []) + { + $this->hookMediator->afterRemove($entity, $options); + } } diff --git a/application/Espo/ORM/Repository/RDBSelectBuilder.php b/application/Espo/ORM/Repository/RDBSelectBuilder.php new file mode 100644 index 0000000000..6e3753746d --- /dev/null +++ b/application/Espo/ORM/Repository/RDBSelectBuilder.php @@ -0,0 +1,336 @@ +entityManager = $entityManager; + + $this->entityType = $entityType; + + $this->repository = $this->entityManager->getRepository($entityType); + + if ($query && $query->getFrom() !== $entityType) { + throw new RuntimeException("SelectBuilder: Passed query doesn't match the entity type."); + } + + $this->builder = new SelectBuilder($entityManager->getQueryComposer()); + + if ($query) { + $this->builder->clone($query); + } + + if (!$query) { + $this->builder->from($entityType); + } + } + + protected function getMapper() : Mapper + { + return $this->entityManager->getMapper(); + } + + /** + * @param $params @deprecated. Omit it. + */ + public function find(?array $params = null) : Collection + { + $query = $this->getMergedParams($params); + + return $this->getMapper()->select($query); + } + + /** + * @param $params @deprecated. Omit it. + */ + public function findOne(?array $params = null) : ?Entity + { + if ($params !== null) { + $query = $this->getMergedParams($params); + + $collection = $this->repository->clone($query)->limit(0, 1)->find(); + } else { + $collection = $this->limit(0, 1)->find(); + } + + foreach ($collection as $entity) { + return $entity; + } + + return null; + } + + /** + * Get a number of records. + * + * @param $params @deprecated. Omit it. + */ + public function count(?array $params = null) : int + { + $query = $this->getMergedParams($params); + + return $this->getMapper()->count($query); + } + + /** + * Get a max value. + * + * @return int|float + */ + public function max(string $attribute) + { + $query = $this->builder->build(); + + return $this->getMapper()->max($query, $attribute); + } + + /** + * Get a min value. + * + * @return int|float + */ + public function min(string $attribute) + { + $query = $this->builder->build(); + + return $this->getMapper()->min($query, $attribute); + } + + /** + * Get a sum value. + * + * @return int|float + */ + public function sum(string $attribute) + { + $query = $this->builder->build(); + + return $this->getMapper()->sum($query, $attribute); + } + + /** + * Add JOIN. + * + * @see Espo\ORM\QueryParams\SelectBuilder::join() + */ + public function join($relationName, ?string $alias = null, ?array $conditions = null) : self + { + $this->builder->join($relationName, $alias, $conditions); + + return $this; + } + + /** + * Add LEFT JOIN. + * + * @see Espo\ORM\QueryParams\SelectBuilder::leftJoin() + */ + public function leftJoin($relationName, ?string $alias = null, ?array $conditions = null) : self + { + $this->builder->leftJoin($relationName, $alias, $conditions); + + return $this; + } + + /** + * Set DISTINCT parameter. + */ + public function distinct() : self + { + $this->builder->distinct(); + + return $this; + } + + /** + * Set to return STH collection. Recommended for fetching large number of records. + * + * @todo Remove. + */ + public function sth() : self + { + $this->builder->sth(); + + return $this; + } + + /** + * Add a WHERE clause. + * + * @see Espo\ORM\QueryParams\SelectBuilder::where() + */ + public function where($keyOrClause = [], $value = null) : self + { + $this->builder->where($keyOrClause, $value); + + return $this; + } + + /** + * Add a HAVING clause. + * + * @see Espo\ORM\QueryParams\SelectBuilder::having() + */ + public function having($keyOrClause = [], $value = null) : self + { + $this->builder->having($keyOrClause, $value); + + return $this; + } + + /** + * Apply ORDER. + * + * @param string|array $orderBy An attribute to order by or order definitions as an array. + * @param bool|string $direction TRUE for DESC order. + */ + public function order($orderBy = 'id', $direction = 'ASC') : self + { + $this->builder->order($orderBy, $direction); + + return $this; + } + + /** + * Apply OFFSET and LIMIT. + */ + public function limit(?int $offset = null, ?int $limit = null) : self + { + $this->builder->limit($offset, $limit); + + return $this; + } + + /** + * Specify SELECT. Which attributes to select. All attributes are selected by default. + * + * @see Espo\ORM\QueryParams\SelectBuilder::select() + * + * @param array|string $select + */ + public function select($select, ?string $alias = null) : self + { + $this->builder->select($select, $alias); + + return $this; + } + + /** + * Specify GROUP BY. + */ + public function groupBy(array $groupBy) : self + { + $this->builder->groupBy($groupBy); + + return $this; + } + + /** + * For backward compatibility. + * @todo Remove. + */ + protected function getMergedParams(?array $params = null) : Select + { + if (!$params || empty($params)) { + return $this->builder->build(); + } + + $params = $params ?? []; + + $builtParams = $this->builder->build()->getRawParams(); + + $whereClause = $builtParams['whereClause'] ?? []; + $havingClause = $builtParams['havingClause'] ?? []; + $joins = $builtParams['joins'] ?? []; + $leftJoins = $builtParams['leftJoins'] ?? []; + + if (!empty($params['whereClause'])) { + unset($builtParams['whereClause']); + if (count($whereClause)) { + $params['whereClause'][] = $whereClause; + } + } + + if (!empty($params['havingClause'])) { + unset($builtParams['havingClause']); + if (count($havingClause)) { + $params['havingClause'][] = $havingClause; + } + } + + if (empty($params['whereClause'])) { + unset($params['whereClause']); + } + + if (empty($params['havingClause'])) { + unset($params['havingClause']); + } + + if (!empty($params['leftJoins']) && !empty($leftJoins)) { + foreach ($leftJoins as $j) { + $params['leftJoins'][] = $j; + } + } + + if (!empty($params['joins']) && !empty($joins)) { + foreach ($joins as $j) { + $params['joins'][] = $j; + } + } + + $params = array_replace_recursive($builtParams, $params); + + return Select::fromRaw($params); + } +} diff --git a/application/Espo/ORM/Repository.php b/application/Espo/ORM/Repository/Repository.php similarity index 92% rename from application/Espo/ORM/Repository.php rename to application/Espo/ORM/Repository/Repository.php index 9fd3818dec..dd7af1f1c3 100644 --- a/application/Espo/ORM/Repository.php +++ b/application/Espo/ORM/Repository/Repository.php @@ -27,7 +27,13 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM; +namespace Espo\ORM\Repository; + +use Espo\ORM\{ + Entity, + EntityManager, + EntityFactory, +}; /** * An access point for fetching and storing records. @@ -40,17 +46,16 @@ abstract class Repository protected $seed; - protected $entityClassName; - protected $entityType; public function __construct(string $entityType, EntityManager $entityManager, EntityFactory $entityFactory) { $this->entityType = $entityType; + $this->entityFactory = $entityFactory; - $this->seed = $this->entityFactory->create($entityType); - $this->entityClassName = get_class($this->seed); $this->entityManager = $entityManager; + + $this->seed = $this->entityFactory->create($entityType); } protected function getEntityFactory() : EntityFactory @@ -69,12 +74,12 @@ abstract class Repository } /** - * Get entity. If $id is NULL, a new entity is returned. + * Get an entity. If $id is NULL, a new entity is returned. */ abstract public function get(?string $id = null) : ?Entity; /** - * Store entity. + * Store an entity. */ abstract public function save(Entity $entity); } diff --git a/application/Espo/ORM/RepositoryFactory.php b/application/Espo/ORM/Repository/RepositoryFactory.php similarity index 94% rename from application/Espo/ORM/RepositoryFactory.php rename to application/Espo/ORM/Repository/RepositoryFactory.php index 4c369a8d29..58973fd9b0 100644 --- a/application/Espo/ORM/RepositoryFactory.php +++ b/application/Espo/ORM/Repository/RepositoryFactory.php @@ -27,9 +27,9 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -namespace Espo\ORM; +namespace Espo\ORM\Repository; interface RepositoryFactory { - public function create(string $name) : object; + public function create(string $name) : Repository; } diff --git a/application/Espo/ORM/SthCollection.php b/application/Espo/ORM/SthCollection.php index 009c56b8df..f04cee5d60 100644 --- a/application/Espo/ORM/SthCollection.php +++ b/application/Espo/ORM/SthCollection.php @@ -29,103 +29,136 @@ namespace Espo\ORM; +use Espo\ORM\{ + QueryParams\Select as SelectQuery, + QueryComposer\QueryComposer as QueryComposer, +}; + +use IteratorAggregate; +use Countable; +use PDO; + /** * Reasonable to use when selecting a large number of records. * It doesn't allocate a memory for every entity. * Entities are fetched on each iteration while traversing a collection. + * + * STH stands for Statement Handle. */ -class SthCollection implements \IteratorAggregate, Collection +class SthCollection implements Collection, IteratorAggregate, Countable { - protected $entityManager = null; + protected $entityManager; protected $entityType; - protected $selectParams = null; + protected $query = null; private $sth = null; private $sql = null; - protected $isFetched = false; + protected $entityList = []; - public function __construct(string $entityType, EntityManager $entityManager = null, array $selectParams = []) + protected function __construct(EntityManager $entityManager) { - $this->selectParams = $selectParams; - $this->entityType = $entityType; $this->entityManager = $entityManager; } - protected function getQuery() + protected function getQueryComposer() : QueryComposer { - return $this->entityManager->getQuery(); + return $this->entityManager->getQueryComposer(); } - protected function getPdo() - { - return $this->entityManager->getPdo(); - } - - protected function getEntityFactory() + protected function getEntityFactory() : EntityFactory { return $this->entityManager->getEntityFactory(); } - /** - * Get select parameters. - */ - public function setSelectParams(array $selectParams) - { - $this->selectParams = $selectParams; - } - - /** - * Set an SQL query. - */ - public function setQuery(?string $sql) + protected function setSql(string $sql) { $this->sql = $sql; } - /** - * Run an SQL query. - */ - public function executeQuery() + protected function getPDO() : PDO { - if ($this->sql) { - $sql = $this->sql; - } else { - $sql = $this->getQuery()->createSelectQuery($this->entityType, $this->selectParams); - } - $sth = $this->getPdo()->prepare($sql); + return $this->entityManager->getPDO(); + } + + protected function executeQuery() + { + $sql = $this->getSql(); + + $sth = $this->getPDO()->prepare($sql); + $sth->execute(); $this->sth = $sth; } + protected function getSql() : string + { + if (!$this->sql) { + $this->sql = $this->getQueryComposer()->compose($this->getQuery()); + } + + return $this->sql; + } + + protected function getQuery() : SelectQuery + { + return $this->query; + } + public function getIterator() { return (function () { if (isset($this->sth)) { $this->sth->execute(); } + while ($row = $this->fetchRow()) { $entity = $this->getEntityFactory()->create($this->entityType); + $entity->set($row); $entity->setAsFetched(); + $this->prepareEntity($entity); + + $this->entityList[] = $entity; + yield $entity; } })(); } - protected function fetchRow() + protected function executeQueryIfNotExecuted() { if (!$this->sth) { $this->executeQuery(); } + } + + protected function fetchRow() + { + $this->executeQueryIfNotExecuted(); + return $this->sth->fetch(\PDO::FETCH_ASSOC); } + public function count() : int + { + $this->executeQueryIfNotExecuted(); + + $rowCount = $this->sth->rowCount(); + + // MySQL may not return a row count for select queries. + if ($rowCount) { + return $rowCount; + } + + return count($this->getValueMapList()); + } + protected function prepareEntity(Entity $entity) { } @@ -152,28 +185,13 @@ class SthCollection implements \IteratorAggregate, Collection return $this->toArray(true); } - /** - * Mark as fetched from DB. - */ - public function setAsFetched() - { - $this->isFetched = true; - } /** - * Mark as not fetched from DB. - */ - public function setAsNotFetched() - { - $this->isFetched = false; - } - - /** - * Is fetched from DB. + * Whether Is fetched from DB. SthCollection is always fetched. */ public function isFetched() : bool { - return $this->isFetched; + return true; } /** @@ -183,4 +201,24 @@ class SthCollection implements \IteratorAggregate, Collection { return $this->entityType; } + + public static function fromQuery(SelectQuery $query, EntityManager $entityManager) : self + { + $obj = new self($entityManager); + + $obj->entityType = $query->getFrom(); + $obj->query = $query; + + return $obj; + } + + public static function fromSql(string $entityType, string $sql, EntityManager $entityManager) : self + { + $obj = new self($entityManager); + + $obj->entityType = $entityType; + $obj->sql = $sql; + + return $obj; + } } diff --git a/application/Espo/Repositories/EmailAddress.php b/application/Espo/Repositories/EmailAddress.php index 11f17805bb..0b989456ee 100644 --- a/application/Espo/Repositories/EmailAddress.php +++ b/application/Espo/Repositories/EmailAddress.php @@ -94,13 +94,13 @@ class EmailAddress extends \Espo\Core\Repositories\Database implements $emailAddressList = $this ->select(['name', 'lower', 'invalid', 'optOut', ['ee.primary', 'primary']]) - ->join([[ + ->join( 'EntityEmailAddress', 'ee', [ 'ee.emailAddressId:' => 'id', ] - ]]) + ) ->where([ 'ee.entityId' => $entity->id, 'ee.entityType' => $entity->getEntityType(), @@ -249,10 +249,9 @@ class EmailAddress extends \Espo\Core\Repositories\Database implements public function getEntityByAddress( string $address, ?string $entityType = null, array $order = ['User', 'Contact', 'Lead', 'Account'] ) : ?Entity { - $selectBuilder = $this->getEntityManager()->createSelectBuilder(); + $selectBuilder = $this->getEntityManager()->getRepository('EntityEmailAddress')->select(); $selectBuilder - ->from('EntityEmailAddress') ->select(['entityType', 'entityId']) ->sth() ->join('EmailAddress', 'ea', ['ea.id:' => 'emailAddressId', 'ea.deleted' => 0]) @@ -262,7 +261,6 @@ class EmailAddress extends \Espo\Core\Repositories\Database implements ['primary', 'DESC'], ]); - if ($entityType) { $selectBuilder->where('entityType=', $entityType); } @@ -290,8 +288,6 @@ class EmailAddress extends \Espo\Core\Repositories\Database implements public function storeEntityEmailAddressData(Entity $entity) { - $pdo = $this->getEntityManager()->getPDO(); - $emailAddressValue = $entity->get('emailAddress'); if (is_string($emailAddressValue)) { $emailAddressValue = trim($emailAddressValue); @@ -410,17 +406,21 @@ class EmailAddress extends \Espo\Core\Repositories\Database implements foreach ($toRemoveList as $address) { $emailAddress = $this->getByAddress($address); - if ($emailAddress) { - $sql = $this->getEntityManager()->getQuery()->createDeleteQuery('EntityEmailAddress', [ - 'whereClause' => [ - 'entityId' => $entity->id, - 'entityType' => $entity->getEntityType(), - 'emailAddressId' => $emailAddress->id, - ], - ]); - $sth = $pdo->prepare($sql); - $sth->execute(); + if (!$emailAddress) { + continue; } + + $delete = $this->getEntityManager()->getQueryBuilder() + ->delete() + ->from('EntityEmailAddress') + ->where([ + 'entityId' => $entity->id, + 'entityType' => $entity->getEntityType(), + 'emailAddressId' => $emailAddress->id, + ]) + ->build(); + + $this->getEntityManager()->getQueryExecutor()->run($delete); } foreach ($toUpdateList as $address) { @@ -496,8 +496,10 @@ class EmailAddress extends \Espo\Core\Repositories\Database implements $emailAddress = $this->getByAddress($primary); if ($emailAddress) { - $updateSelect = $this->getEntityManager()->createSelectBuilder() + $update = $this->getEntityManager()->getQueryBuilder() + ->update() ->from('EntityEmailAddress') + ->set(['primary' => false]) ->where([ 'entityId' => $entity->id, 'entityType' => $entity->getEntityType(), @@ -506,10 +508,12 @@ class EmailAddress extends \Espo\Core\Repositories\Database implements ]) ->build(); - $this->getEntityManager()->getQueryExecutor()->update($updateSelect, ['primary' => false]); + $this->getEntityManager()->getQueryExecutor()->run($update); - $updateSelect = $this->getEntityManager()->createSelectBuilder() + $update = $this->getEntityManager()->getQueryBuilder() + ->update() ->from('EntityEmailAddress') + ->set(['primary' => true]) ->where([ 'entityId' => $entity->id, 'entityType' => $entity->getEntityType(), @@ -518,7 +522,7 @@ class EmailAddress extends \Espo\Core\Repositories\Database implements ]) ->build(); - $this->getEntityManager()->getQueryExecutor()->update($updateSelect, ['primary' => true]); + $this->getEntityManager()->getQueryExecutor()->run($update); } } @@ -537,8 +541,6 @@ class EmailAddress extends \Espo\Core\Repositories\Database implements { if (!$entity->has('emailAddress')) return; - $pdo = $this->getEntityManager()->getPDO(); - $emailAddressValue = $entity->get('emailAddress'); if (is_string($emailAddressValue)) { $emailAddressValue = trim($emailAddressValue); @@ -574,8 +576,10 @@ class EmailAddress extends \Espo\Core\Repositories\Database implements $this->markAddressOptedOut($emailAddressValue, !!$entity->get('emailAddressIsOptedOut')); } - $updateSelect = $this->getEntityManager()->createSelectBuilder() + $update = $this->getEntityManager()->getQueryBuilder() + ->update() ->from('EntityEmailAddress') + ->set(['primary' => true]) ->where([ 'entityId' => $entity->id, 'entityType' => $entity->getEntityType(), @@ -583,7 +587,7 @@ class EmailAddress extends \Espo\Core\Repositories\Database implements ]) ->build(); - $this->getEntityManager()->getQueryExecutor()->update($updateSelect, ['primary' => true]); + $this->getEntityManager()->getQueryExecutor()->run($update); } else { if ( diff --git a/application/Espo/Repositories/Import.php b/application/Espo/Repositories/Import.php index 59f6c99890..b8a8b9cab9 100644 --- a/application/Espo/Repositories/Import.php +++ b/application/Espo/Repositories/Import.php @@ -29,11 +29,14 @@ namespace Espo\Repositories; -use Espo\ORM\Entity; +use Espo\ORM\{ + Entity, + Collection, +}; class Import extends \Espo\Core\Repositories\Database { - public function findRelated(Entity $entity, string $relationName, array $params = []) : \Traversable + public function findRelated(Entity $entity, string $relationName, ?array $params = []) { $entityType = $entity->get('entityType'); @@ -76,7 +79,7 @@ class Import extends \Espo\Core\Repositories\Database ]; } - public function countRelated(Entity $entity, string $relationName, array $params = []) : int + public function countRelated(Entity $entity, string $relationName, ?array $params = null) : int { $entityType = $entity->get('entityType'); @@ -94,13 +97,15 @@ class Import extends \Espo\Core\Repositories\Database } } - $sql = $this->getEntityManager()->getQuery()->createDeleteQuery('ImportEntity', [ - 'whereClause' => [ + $delete = $this->getEntityManager()->getQueryBuilder() + ->delete() + ->from('ImportEntity') + ->where([ 'importId' => $entity->id, - ] - ]); + ]) + ->build(); - $this->getEntityManager()->getPDO()->query($sql); + $this->getEntityManager()->getQueryExecutor()->run($delete); parent::afterRemove($entity, $options); } diff --git a/application/Espo/Repositories/PhoneNumber.php b/application/Espo/Repositories/PhoneNumber.php index 6e7efd2d06..76f34e927c 100644 --- a/application/Espo/Repositories/PhoneNumber.php +++ b/application/Espo/Repositories/PhoneNumber.php @@ -92,13 +92,13 @@ class PhoneNumber extends \Espo\Core\Repositories\Database implements $numberList = $this ->select(['name', 'type', 'invalid', 'optOut', ['en.primary', 'primary']]) - ->join([[ + ->join( 'EntityPhoneNumber', 'en', [ 'en.phoneNumberId:' => 'id', ] - ]]) + ) ->where([ 'en.entityId' => $entity->id, 'en.entityType' => $entity->getEntityType(), @@ -211,8 +211,6 @@ class PhoneNumber extends \Espo\Core\Repositories\Database implements protected function storeEntityPhoneNumberData(Entity $entity) { - $pdo = $this->getEntityManager()->getPDO(); - $phoneNumberValue = $entity->get('phoneNumber'); if (is_string($phoneNumberValue)) { $phoneNumberValue = trim($phoneNumberValue); @@ -335,17 +333,21 @@ class PhoneNumber extends \Espo\Core\Repositories\Database implements foreach ($toRemoveList as $number) { $phoneNumber = $this->getByNumber($number); - if ($phoneNumber) { - $sql = $this->getEntityManager()->getQuery()->createDeleteQuery('EntityPhoneNumber', [ - 'whereClause' => [ - 'entityId' => $entity->id, - 'entityType' => $entity->getEntityType(), - 'phoneNumberId' => $phoneNumber->id, - ], - ]); - $sth = $pdo->prepare($sql); - $sth->execute(); + if (!$phoneNumber) { + continue; } + + $delete = $this->getEntityManager()->getQueryBuilder() + ->delete() + ->from('EntityPhoneNumber') + ->where([ + 'entityId' => $entity->id, + 'entityType' => $entity->getEntityType(), + 'phoneNumberId' => $phoneNumber->id, + ]) + ->build(); + + $this->getEntityManager()->getQueryExecutor()->run($delete); } foreach ($toUpdateList as $number) { @@ -422,10 +424,12 @@ class PhoneNumber extends \Espo\Core\Repositories\Database implements if ($primary) { $phoneNumber = $this->getByNumber($primary); - if ($phoneNumber) { - $updateSelect = $this->getEntityManager()->createSelectBuilder() + if ($phoneNumber) { + $update = $this->getEntityManager()->getQueryBuilder() + ->update() ->from('EntityPhoneNumber') + ->set(['primary' => false]) ->where([ 'entityId' => $entity->id, 'entityType' => $entity->getEntityType(), @@ -434,10 +438,12 @@ class PhoneNumber extends \Espo\Core\Repositories\Database implements ]) ->build(); - $this->getEntityManager()->getQueryExecutor()->update($updateSelect, ['primary' => false]); + $this->getEntityManager()->getQueryExecutor()->run($update); - $updateSelect = $this->getEntityManager()->createSelectBuilder() + $update = $this->getEntityManager()->getQueryBuilder() + ->update() ->from('EntityPhoneNumber') + ->set(['primary' => true]) ->where([ 'entityId' => $entity->id, 'entityType' => $entity->getEntityType(), @@ -446,7 +452,7 @@ class PhoneNumber extends \Espo\Core\Repositories\Database implements ]) ->build(); - $this->getEntityManager()->getQueryExecutor()->update($updateSelect, ['primary' => true]); + $this->getEntityManager()->getQueryExecutor()->run($update); } } @@ -464,8 +470,6 @@ class PhoneNumber extends \Espo\Core\Repositories\Database implements protected function storeEntityPhoneNumberPrimary(Entity $entity) { - $pdo = $this->getEntityManager()->getPDO(); - if (!$entity->has('phoneNumber')) return; $phoneNumberValue = trim($entity->get('phoneNumber')); @@ -502,8 +506,10 @@ class PhoneNumber extends \Espo\Core\Repositories\Database implements $this->markNumberOptedOut($phoneNumberValue, !!$entity->get('phoneNumberIsOptedOut')); } - $updateSelect = $this->getEntityManager()->createSelectBuilder() + $update = $this->getEntityManager()->getQueryBuilder() + ->update() ->from('EntityPhoneNumber') + ->set( ['primary' => true]) ->where([ 'entityId' => $entity->id, 'entityType' => $entity->getEntityType(), @@ -511,7 +517,7 @@ class PhoneNumber extends \Espo\Core\Repositories\Database implements ]) ->build(); - $this->getEntityManager()->getQueryExecutor()->update($updateSelect, ['primary' => true]); + $this->getEntityManager()->getQueryExecutor()->run($update); } else { if ( diff --git a/application/Espo/Repositories/Preferences.php b/application/Espo/Repositories/Preferences.php index 3731876640..2c9db41c03 100644 --- a/application/Espo/Repositories/Preferences.php +++ b/application/Espo/Repositories/Preferences.php @@ -30,18 +30,14 @@ namespace Espo\Repositories; use Espo\ORM\Entity; -use Espo\ORM\Repository; +use Espo\ORM\Repository\Repository; use Espo\Core\Utils\Json; -use Espo\ORM\Repositories\{ - Removable, -}; - use PDO; use Espo\Core\Di; -class Preferences extends Repository implements Removable, +class Preferences extends Repository implements Di\MetadataAware, Di\ConfigAware, Di\EntityManagerAware @@ -87,18 +83,17 @@ class Preferences extends Repository implements Removable, { $data = null; - $pdo = $this->entityManager->getPDO(); - - $sql = $this->getEntityManager()->getQuery()->createSelectQuery('Preferences', [ - 'select' => ['id', 'data'], - 'whereClause' => [ + $select = $this->getEntityManager()->getQueryBuilder() + ->select() + ->from('Preferences') + ->select(['id', 'data']) + ->where([ 'id' => $id, - ], - 'limit' => 1, - ]); + ]) + ->limit(0, 1) + ->build(); - $sth = $pdo->prepare($sql); - $sth->execute(); + $sth = $this->getEntityManager()->getQueryExecutor()->run($select); while ($row = $sth->fetch(PDO::FETCH_ASSOC)) { $data = Json::decode($row['data']); @@ -171,13 +166,15 @@ class Preferences extends Repository implements Removable, $entityTypeList = $entity->get('autoFollowEntityTypeList') ?? []; - $sql = $this->entityManager->getQuery()->createDeleteQuery('Autofollow', [ - 'whereClause' => [ + $delete = $this->entityManager->getQueryBuilder() + ->delete() + ->from('Autofollow') + ->where([ 'userId' => $id, - ], - ]); + ]) + ->build(); - $this->entityManager->getPDO()->query($sql); + $this->entityManager->getQueryExecutor()->run($delete); $entityTypeList = array_filter($entityTypeList, function ($item) { return (bool) $this->metadata->get(['scopes', $item, 'stream']); @@ -208,20 +205,20 @@ class Preferences extends Repository implements Removable, $dataString = Json::encode($data, \JSON_PRETTY_PRINT); - $pdo = $this->entityManager->getPDO(); - - $sql = $this->entityManager->getQuery()->createInsertQuery('Preferences', [ - 'columns' => ['id', 'data'], - 'values' => [ + $insert = $this->getEntityManager()->getQueryBuilder() + ->insert() + ->into('Preferences') + ->columns(['id', 'data']) + ->values([ 'id' => $entity->id, 'data' => $dataString, - ], - 'update' => [ + ]) + ->updateSet([ 'data' => $dataString, - ], - ]); + ]) + ->build(); - $pdo->query($sql); + $this->getEntityManager()->getQueryExecutor()->run($insert); $user = $this->entityManager->getEntity('User', $entity->id); if ($user && !$user->isPortal()) { @@ -233,15 +230,15 @@ class Preferences extends Repository implements Removable, public function deleteFromDb(string $id) { - $pdo = $this->entityManager->getPDO(); - - $sql = $this->entityManager->getQuery()->createDeleteQuery('Preferences', [ - 'whereClause' => [ + $delete = $this->getEntityManager()->getQueryBuilder() + ->delete() + ->from('Preferences') + ->where([ 'id' => $id, - ], - ]); + ]) + ->build(); - $pdo->query($sql); + $this->getEntityManager()->getQueryExecutor()->run($delete); } public function remove(Entity $entity, array $options = []) diff --git a/application/Espo/Services/App.php b/application/Espo/Services/App.php index 6efa7223d6..1c3b843283 100644 --- a/application/Espo/Services/App.php +++ b/application/Espo/Services/App.php @@ -334,7 +334,9 @@ class App $this->dataManager->rebuild(); } - // TODO remove in 5.5.0 + /** + * @todo Remove in 6.0. + */ public function jobPopulatePhoneNumberNumeric() { $numberList = $this->entityManager->getRepository('PhoneNumber')->find(); @@ -343,7 +345,9 @@ class App } } - // TODO remove in 5.5.0 + /** + * @todo Remove in 6.0. Move to another place. CLI command. + */ public function jobPopulateArrayValues() { $scopeList = array_keys($this->metadata->get(['scopes'])); @@ -373,7 +377,7 @@ class App $orGroup[$attribute . '!='] = null; } - $sql = $this->entityManager->getQuery()->createSelectQuery($scope, [ + $sql = $this->entityManager->getQueryComposer()->createSelectQuery($scope, [ 'select' => $select, 'whereClause' => [ 'OR' => $orGroup @@ -394,77 +398,9 @@ class App } } - // TODO remove in 5.5.0 - public function jobPopulateNotesTeamUser() - { - $aclManager = $this->aclManager; - - $sql = $this->entityManager->getQuery()->createSelectQuery('Note', [ - 'whereClause' => [ - 'parentId!=' => null, - 'type=' => ['Relate', 'CreateRelated', 'EmailReceived', 'EmailSent', 'Assign', 'Create'], - ], - 'limit' => 100000, - 'orderBy' => [['number', 'DESC']] - ]); - $sth = $this->entityManager->getPdo()->prepare($sql); - $sth->execute(); - - $i = 0; - while ($dataRow = $sth->fetch(\PDO::FETCH_ASSOC)) { - $i++; - $note = $this->entityManager->getEntityFactory()->create('Note'); - $note->set($dataRow); - $note->setAsFetched(); - - if ($note->get('relatedId') && $note->get('relatedType')) { - $targetType = $note->get('relatedType'); - $targetId = $note->get('relatedId'); - } else if ($note->get('parentId') && $note->get('parentType')) { - $targetType = $note->get('parentType'); - $targetId = $note->get('parentId'); - } else { - continue; - } - - if (!$this->entityManager->hasRepository($targetType)) continue; - - try { - $entity = $this->entityManager->getEntity($targetType, $targetId); - if (!$entity) continue; - $ownerUserIdAttribute = $aclManager->getImplementation($targetType)->getOwnerUserIdAttribute($entity); - $toSave = false; - if ($ownerUserIdAttribute) { - if ($entity->getAttributeParam($ownerUserIdAttribute, 'isLinkMultipleIdList')) { - $link = $entity->getAttributeParam($ownerUserIdAttribute, 'relation'); - $userIdList = $entity->getLinkMultipleIdList($link); - } else { - $userId = $entity->get($ownerUserIdAttribute); - if ($userId) { - $userIdList = [$userId]; - } else { - $userIdList = []; - } - } - if (!empty($userIdList)) { - $note->set('usersIds', $userIdList); - $toSave = true; - } - } - if ($entity->hasLinkMultipleField('teams')) { - $teamIdList = $entity->getLinkMultipleIdList('teams'); - if (!empty($teamIdList)) { - $note->set('teamsIds', $teamIdList); - $toSave = true; - } - } - if ($toSave) { - $this->entityManager->saveEntity($note); - } - } catch (\Exception $e) {} - } - } - + /** + * @todo Remove in 6.0. Move to another place. CLI command. + */ public function jobPopulateOptedOutPhoneNumbers() { $entityTypeList = ['Contact', 'Lead']; diff --git a/application/Espo/Services/DashboardTemplate.php b/application/Espo/Services/DashboardTemplate.php index 64d30daa58..5deb40bb66 100644 --- a/application/Espo/Services/DashboardTemplate.php +++ b/application/Espo/Services/DashboardTemplate.php @@ -109,7 +109,7 @@ class DashboardTemplate extends Record $team = $this->getEntityManager()->fetchEntity('Team', $teamId); if (!$team) throw new NotFound(); - $userList = $this->getEntityManager()->getRepository('User')->join(['teams'])->distinct()->where([ + $userList = $this->getEntityManager()->getRepository('User')->join('teams')->distinct()->where([ 'teams.id' => $teamId, ])->find(); diff --git a/application/Espo/Services/Email.php b/application/Espo/Services/Email.php index 198754ce93..6c1d959196 100644 --- a/application/Espo/Services/Email.php +++ b/application/Espo/Services/Email.php @@ -522,8 +522,9 @@ class Email extends Record implements { $userId = $userId ?? $this->getUser()->id; - $select = $this->entityManager->createSelectBuilder() + $update = $this->entityManager->getQueryBuilder()->update() ->from('EmailUser') + ->set(['isRead' => true]) ->where([ 'deleted' => false, 'userId' => $userId, @@ -531,10 +532,11 @@ class Email extends Record implements ]) ->build(); - $this->entityManager->getQueryExecutor()->update($select, ['isRead' => true]); + $this->entityManager->getQueryExecutor()->run($update); - $select = $this->entityManager->createSelectBuilder() + $update = $this->entityManager->getQueryBuilder()->update() ->from('Notification') + ->set(['read' => true]) ->where([ 'deleted' => false, 'userId' => $userId, @@ -544,7 +546,7 @@ class Email extends Record implements ]) ->build(); - $this->entityManager->getQueryExecutor()->update($select, ['read' => true]); + $this->entityManager->getQueryExecutor()->run($update); return true; } @@ -553,8 +555,9 @@ class Email extends Record implements { $userId = $userId ?? $this->getUser()->id; - $select = $this->entityManager->createSelectBuilder() + $update = $this->entityManager->getQueryBuilder()->update() ->from('EmailUser') + ->set(['isRead' => true]) ->where([ 'deleted' => false, 'userId' => $userId, @@ -562,7 +565,7 @@ class Email extends Record implements ]) ->build(); - $this->entityManager->getQueryExecutor()->update($select, ['isRead' => true]); + $this->entityManager->getQueryExecutor()->run($update); $this->markNotificationAsRead($id, $userId); @@ -573,8 +576,9 @@ class Email extends Record implements { $userId = $userId ?? $this->getUser()->id; - $select = $this->entityManager->createSelectBuilder() + $update = $this->entityManager->getQueryBuilder()->update() ->from('EmailUser') + ->set(['isRead' => false]) ->where([ 'deleted' => false, 'userId' => $userId, @@ -582,7 +586,7 @@ class Email extends Record implements ]) ->build(); - $this->entityManager->getQueryExecutor()->update($select, ['isRead' => false]); + $this->entityManager->getQueryExecutor()->run($update); return true; } @@ -591,8 +595,9 @@ class Email extends Record implements { $userId = $userId ?? $this->getUser()->id; - $select = $this->entityManager->createSelectBuilder() + $update = $this->entityManager->getQueryBuilder()->update() ->from('EmailUser') + ->set(['isImportant' => true]) ->where([ 'deleted' => false, 'userId' => $userId, @@ -600,7 +605,7 @@ class Email extends Record implements ]) ->build(); - $this->entityManager->getQueryExecutor()->update($select, ['isImportant' => true]); + $this->entityManager->getQueryExecutor()->run($update); return true; } @@ -609,8 +614,9 @@ class Email extends Record implements { $userId = $userId ?? $this->getUser()->id; - $select = $this->entityManager->createSelectBuilder() + $update = $this->entityManager->getQueryBuilder()->update() ->from('EmailUser') + ->set(['isImportant' => false]) ->where([ 'deleted' => false, 'userId' => $userId, @@ -618,7 +624,7 @@ class Email extends Record implements ]) ->build(); - $this->entityManager->getQueryExecutor()->update($select, ['isImportant' => false]); + $this->entityManager->getQueryExecutor()->run($update); return true; } @@ -627,8 +633,9 @@ class Email extends Record implements { $userId = $userId ?? $this->getUser()->id; - $select = $this->entityManager->createSelectBuilder() + $update = $this->entityManager->getQueryBuilder()->update() ->from('EmailUser') + ->set(['inTrash' => true]) ->where([ 'deleted' => false, 'userId' => $userId, @@ -636,7 +643,7 @@ class Email extends Record implements ]) ->build(); - $this->entityManager->getQueryExecutor()->update($select, ['inTrash' => true]); + $this->entityManager->getQueryExecutor()->run($update); $this->markNotificationAsRead($id, $userId); @@ -647,8 +654,9 @@ class Email extends Record implements { $userId = $userId ?? $this->getUser()->id; - $select = $this->entityManager->createSelectBuilder() + $update = $this->entityManager->getQueryBuilder()->update() ->from('EmailUser') + ->set(['inTrash' => false]) ->where([ 'deleted' => false, 'userId' => $userId, @@ -656,15 +664,16 @@ class Email extends Record implements ]) ->build(); - $this->entityManager->getQueryExecutor()->update($select, ['inTrash' => false]); + $this->entityManager->getQueryExecutor()->run($update); return true; } public function markNotificationAsRead(string $id, string $userId) { - $select = $this->entityManager->createSelectBuilder() + $update = $this->entityManager->getQueryBuilder()->update() ->from('Notification') + ->set(['read' => true]) ->where([ 'deleted' => false, 'userId' => $userId, @@ -675,7 +684,7 @@ class Email extends Record implements ]) ->build(); - $this->entityManager->getQueryExecutor()->update($select, ['read' => true]); + $this->entityManager->getQueryExecutor()->run($update); } public function moveToFolder(string $id, ?string $folderId, ?string $userId = null) @@ -686,8 +695,9 @@ class Email extends Record implements $folderId = null; } - $select = $this->entityManager->createSelectBuilder() + $update = $this->entityManager->getQueryBuilder()->update() ->from('EmailUser') + ->set(['folderId' => $folderId]) ->where([ 'deleted' => false, 'userId' => $userId, @@ -695,7 +705,7 @@ class Email extends Record implements ]) ->build(); - $this->entityManager->getQueryExecutor()->update($select, ['folderId' => $folderId]); + $this->entityManager->getQueryExecutor()->run($update); return true; } diff --git a/application/Espo/Services/EmailAddress.php b/application/Espo/Services/EmailAddress.php index 1fbc5564f4..6697cfe23a 100644 --- a/application/Espo/Services/EmailAddress.php +++ b/application/Espo/Services/EmailAddress.php @@ -144,26 +144,20 @@ class EmailAddress extends Record return []; } - $pdo = $this->getEntityManager()->getPDO(); + $list = $this->getEntityManager()->getRepository('InboundEmail') + ->select(['id', 'name', 'emailAddress']) + ->where([ + 'emailAddress*' => $query . '%', + ]) + ->order('name') + ->find(); - $selectParams = [ - 'select' => ['id', 'name', 'emailAddress'], - 'whereClause' => [ - 'emailAddress*' => $query . '%' - ], - 'orderBy' => 'name', - ]; - - $sql = $this->getEntityManager()->getQuery()->createSelectQuery('InboundEmail', $selectParams); - - $sth = $pdo->prepare($sql); - $sth->execute(); - while ($row = $sth->fetch(\PDO::FETCH_ASSOC)) { + foreach ($list as $item) { $result[] = [ - 'emailAddress' => $row['emailAddress'], - 'entityName' => $row['name'], + 'emailAddress' => $item->get('emailAddress'), + 'entityName' => $item->get('name'), 'entityType' => 'InboundEmail', - 'entityId' => $row['id'], + 'entityId' => $item->get('id'), ]; } } diff --git a/application/Espo/Services/EmailNotification.php b/application/Espo/Services/EmailNotification.php index 69271b51c8..3f076b5a75 100644 --- a/application/Espo/Services/EmailNotification.php +++ b/application/Espo/Services/EmailNotification.php @@ -222,14 +222,14 @@ class EmailNotification $selectParams = $this->$methodName(); $selectParams['whereClause'][] = $where; - $sqlArr[] = $this->entityManager->getQuery()->createSelectQuery('Notification', $selectParams); + $sqlArr[] = $this->entityManager->getQueryComposer()->createSelectQuery('Notification', $selectParams); } $maxCount = intval(self::PROCESS_MAX_COUNT); $sql = '' . implode(' UNION ', $sqlArr) . " ORDER BY number LIMIT 0, {$maxCount}"; - $notificationList = $this->entityManager->getRepository('Notification')->findByQuery($sql); + $notificationList = $this->entityManager->getRepository('Notification')->findBySql($sql); foreach ($notificationList as $notification) { $notification->set('emailIsProcessed', true); diff --git a/application/Espo/Services/GlobalSearch.php b/application/Espo/Services/GlobalSearch.php index 5ecf8ffa6e..678be0e5dd 100644 --- a/application/Espo/Services/GlobalSearch.php +++ b/application/Espo/Services/GlobalSearch.php @@ -98,7 +98,7 @@ class GlobalSearch implements $selectParams['orderBy'] = [['name']]; } - $itemSql = $this->entityManager->getQuery()->createSelectQuery($entityType, $selectParams); + $itemSql = $this->entityManager->getQueryComposer()->createSelectQuery($entityType, $selectParams); $unionPartList[] = "(\n" . $itemSql . "\n)"; } @@ -127,7 +127,7 @@ class GlobalSearch implements $sql .= "\nORDER BY name"; } - $sql = $this->entityManager->getQuery()->limit($sql, $offset, $maxSize + 1); + $sql = $this->entityManager->getQueryComposer()->limit($sql, $offset, $maxSize + 1); $sth = $pdo->prepare($sql); $sth->execute(); diff --git a/application/Espo/Services/InboundEmail.php b/application/Espo/Services/InboundEmail.php index 7ae9240a4c..58616ad5dd 100644 --- a/application/Espo/Services/InboundEmail.php +++ b/application/Espo/Services/InboundEmail.php @@ -643,14 +643,15 @@ class InboundEmail extends \Espo\Services\Record implements $case->set('accountId', $email->get('accountId')); } - $contact = $this->getEntityManager()->getRepository('Contact')->join([['emailAddresses', 'emailAddressesMultiple']])->where([ + $contact = $this->getEntityManager()->getRepository('Contact')->join('emailAddresses', 'emailAddressesMultiple')->where([ 'emailAddressesMultiple.id' => $email->get('fromEmailAddressId') ])->findOne(); if ($contact) { $case->set('contactId', $contact->id); } else { if (!$case->get('accountId')) { - $lead = $this->getEntityManager()->getRepository('Lead')->join([['emailAddresses', 'emailAddressesMultiple']])->where([ + $lead = $this->getEntityManager()->getRepository('Lead') + ->join('emailAddresses', 'emailAddressesMultiple')->where([ 'emailAddressesMultiple.id' => $email->get('fromEmailAddressId') ])->findOne(); if ($lead) { diff --git a/application/Espo/Services/Notification.php b/application/Espo/Services/Notification.php index 95085d07bf..58561a8888 100644 --- a/application/Espo/Services/Notification.php +++ b/application/Espo/Services/Notification.php @@ -80,7 +80,6 @@ class Notification extends \Espo\Services\Record implements $collection = $this->entityManager->createCollection(); - $userList = $this->getEntityManager()->getRepository('User') ->select(['id']) ->where([ @@ -176,15 +175,16 @@ class Notification extends \Espo\Services\Record implements public function markAllRead(string $userId) { - $select = $this->entityManager->createSelectBuilder() + $update = $this->entityManager->getQueryBuilder()->update() ->from('Notification') + ->set(['read' => true]) ->where([ 'userId' => $userId, 'read' => false, ]) ->build(); - $this->entityManager->getQueryExecutor()->update($select, ['read' => true]); + $this->entityManager->getQueryExecutor()->run($update); return true; } @@ -276,13 +276,15 @@ class Notification extends \Espo\Services\Record implements } if (!empty($ids)) { - $select = $this->entityManager->createSelectBuilder() + $update = $this->entityManager->getQueryBuilder() + ->update() ->from('Notification') + ->set(['read' => true]) ->where([ 'id' => $ids, ]) ->build(); - $this->entityManager->getQueryExecutor()->update($select, ['read' => true]); + $this->entityManager->getQueryExecutor()->run($update); } return new RecordCollection($collection, $count); diff --git a/application/Espo/Services/Record.php b/application/Espo/Services/Record.php index 1d492c9ffa..f12ae0b002 100644 --- a/application/Espo/Services/Record.php +++ b/application/Espo/Services/Record.php @@ -385,22 +385,28 @@ class Record implements Crud, public function getEntity(?string $id = null) : ?Entity { - if (!is_null($id)) { - $selectParams = []; - if ($this->getUser()->isAdmin()) { - $selectParams['withDeleted'] = true; - } - $entity = $this->getRepository()->getById($id, $selectParams); - } else { - $entity = $this->getRepository()->getNew(); + if ($id === null) { + return $this->getRepository()->getNew(); } - if ($entity && !is_null($id)) { - $this->loadAdditionalFields($entity); - if (!$this->getAcl()->check($entity, 'read')) throw new ForbiddenSilent("No read access."); - $this->prepareEntityForOutput($entity); + $entity = $this->getRepository()->getById($id); + + if (!$entity && $this->getUser()->isAdmin()) { + $entity = $this->getEntityEvenDeleted($id); } + if (!$entity) { + return null; + } + + $this->loadAdditionalFields($entity); + + if (!$this->getAcl()->check($entity, 'read')) { + throw new ForbiddenSilent("No read access."); + } + + $this->prepareEntityForOutput($entity); + return $entity; } @@ -1325,11 +1331,25 @@ class Record implements Crud, ]; } + protected function getEntityEvenDeleted(string $id) : ?Entity + { + $query = $this->entityManager->getQueryBuilder() + ->select() + ->from($this->entityType) + ->where([ + 'id' => $id, + ]) + ->withDeleted() + ->build(); + + return $this->getRepository()->findOne($query); + } + public function restoreDeleted(string $id) { if (!$this->getUser()->isAdmin()) throw new Forbidden(); - $entity = $this->getRepository()->getById($id, ['withDeleted' => true]); + $entity = $this->getEntityEvenDeleted($id); if (!$entity) throw new NotFound(); if (!$entity->get('deleted')) throw new Forbidden(); @@ -2095,7 +2115,10 @@ class Record implements Crud, $this->getEntityManager()->getRepository($this->getEntityType())->handleSelectParams($selectParams); - $collection = $this->getEntityManager()->createSthCollection($this->getEntityType(), $selectParams); + $collection = $this->getEntityManager()->getCollectionFactroy()->createFromQuery( + $this->getEntityType(), + SelectQuery::fromRaw($selectParams) + ); } $attributeListToSkip = [ diff --git a/application/Espo/Services/Stream.php b/application/Espo/Services/Stream.php index 24d8af952f..2913934be4 100644 --- a/application/Espo/Services/Stream.php +++ b/application/Espo/Services/Stream.php @@ -35,6 +35,7 @@ use Espo\Core\Exceptions\NotFound; use Espo\ORM\{ Entity, EntityCollection, + QueryParams\Select, }; use Espo\Entities\User; @@ -240,17 +241,17 @@ class Stream return; } - $pdo = $this->entityManager->getPDO(); - - $sql = $this->entityManager->getQuery()->createDeleteQuery('Subscription', [ - 'whereClause' => [ + $delete = $this->entityManager->getQueryBuilder() + ->delete() + ->from('Subscription') + ->where([ 'userId' => $userIdList, 'entityId' => $entity->id, 'entityType' => $entity->getEntityType(), - ], - ]); + ]) + ->build(); - $pdo->query($sql); + $this->entityManager->getQueryExecutor()->run($delete); $collection = new EntityCollection(); @@ -264,7 +265,7 @@ class Stream $collection[] = $subscription; } - $this->entityManager->getMapper('RDB')->massInsert($collection); + $this->entityManager->getMapper()->massInsert($collection); } public function followEntity(Entity $entity, string $userId, bool $skipAclCheck = false) @@ -309,17 +310,17 @@ class Stream return false; } - $pdo = $this->entityManager->getPDO(); - - $sql = $this->entityManager->getQuery()->createDeleteQuery('Subscription', [ - 'whereClause' => [ + $delete = $this->entityManager->getQueryBuilder() + ->delete() + ->from('Subscription') + ->where([ 'userId' => $userId, 'entityId' => $entity->id, 'entityType' => $entity->getEntityType(), - ], - ]); + ]) + ->build(); - $pdo->query($sql); + $this->entityManager->getQueryExecutor()->run($delete); return true; } @@ -330,16 +331,16 @@ class Stream return; } - $pdo = $this->entityManager->getPDO(); - - $sql = $this->entityManager->getQuery()->createDeleteQuery('Subscription', [ - 'whereClause' => [ + $delete = $this->entityManager->getQueryBuilder() + ->delete() + ->from('Subscription') + ->where([ 'entityId' => $entity->id, 'entityType' => $entity->getEntityType(), - ], - ]); + ]) + ->build(); - $pdo->query($sql); + $this->entityManager->getQueryExecutor()->run($delete); } public function findUserStream($userId, $params = []) @@ -633,11 +634,7 @@ class Stream if ($user->isPortal()) { $portalIdList = $user->getLinkMultipleIdList('portals'); - $portalIdQuotedList = []; - foreach ($portalIdList as $portalId) { - $portalIdQuotedList[] = $pdo->quote($portalId); - } - if (!empty($portalIdQuotedList)) { + if (!empty($portalIdList)) { $selectParamsList[] = [ 'select' => $select, 'leftJoins' => ['portals', 'createdBy'], @@ -730,16 +727,18 @@ class Stream } } - $sqlPartList[] = "(\n" . $this->entityManager->getQuery()->createSelectQuery('Note', $selectParams) . "\n)"; + $sqlPartList[] = "(\n" . $this->entityManager->getQueryComposer()->createSelectQuery('Note', $selectParams) . "\n)"; } $sql = implode("\n UNION ALL \n", $sqlPartList) . " ORDER BY number DESC "; - $sql = $this->entityManager->getQuery()->limit($sql, $offset, $maxSize + 1); + $sql = $this->entityManager->getQueryComposer()->limit($sql, $offset, $maxSize + 1); - $collection = $this->entityManager->getRepository('Note')->findByQuery($sql); + $sthCollection = $this->entityManager->getRepository('Note')->findBySql($sql); + + $collection = $this->entityManager->getCollectionFactory()->createFromSthCollection($sthCollection); foreach ($collection as $e) { $this->loadNoteAdditionalFields($e); @@ -1090,9 +1089,15 @@ class Stream $selectManager->applyLimit($offset, $maxSize + 1, $selectParams); - $sql = $this->entityManager->getQuery()->createSelectQuery('Note', $selectParams); + $selectParams['from'] = 'Note'; - $collection = $this->entityManager->getRepository('Note')->findByQuery($sql); + $select = Select::fromRaw($selectParams); + + $sql = $this->entityManager->getQueryComposer()->compose($select); + + $sthCollection = $this->entityManager->getRepository('Note')->findBySql($sql); + + $collection = $this->entityManager->getCollectionFactory()->createFromSthCollection($sthCollection); foreach ($collection as $e) { $this->loadNoteAdditionalFields($e); @@ -1611,9 +1616,8 @@ class Stream $selectParams['select'] = $selectAttributeList; } - $query = $this->entityManager->getQuery(); + $query = $this->entityManager->getQueryComposer(); $selectParams['t'] = true; - $sql = $query->createSelectQuery('User', $selectParams); $collection = $this->entityManager->getRepository('User')->find($selectParams); $total = $this->entityManager->getRepository('User')->count($selectParams); @@ -1623,49 +1627,41 @@ class Stream public function getEntityFollowers(Entity $entity, $offset = 0, $limit = false) { - $query = $this->entityManager->getQuery(); - $pdo = $this->entityManager->getPDO(); - if (!$limit) { $limit = 200; } - $sql = $query->createSelectQuery('User', [ - 'select' => ['id', 'name'], - 'joins' => [ + $userList = $this->entityManager->getRepository('User') + ->select(['id', 'name']) + ->join( + 'Subscription', + 'subscription', [ - 'Subscription', - 'subscription', - [ - 'subscription.userId=:' => 'user.id', - 'subscription.entityId' => $entity->id, - 'subscription.entityType' => $entity->getEntityType() - ] + 'subscription.userId=:' => 'user.id', + 'subscription.entityId' => $entity->id, + 'subscription.entityType' => $entity->getEntityType() ] - ], - 'offset' => $offset, - 'limit' => $limit, - 'whereClause' => [ - 'isActive' => true - ], - 'orderBy' => [ + ) + ->limit($offset, $limit) + ->where([ + 'isActive' => true, + ]) + ->order([ ['LIST:id:' . $this->user->id, 'DESC'], - ['name'] - ] - ]); - - $sth = $pdo->prepare($sql); - $sth->execute(); + ['name'], + ]) + ->find(); $data = [ 'idList' => [], 'nameMap' => (object) [] ]; - while ($row = $sth->fetch(\PDO::FETCH_ASSOC)) { - $id = $row['id']; + foreach ($userList as $user) { + $id = $user->id; + $data['idList'][] = $id; - $data['nameMap']->$id = $row['name']; + $data['nameMap']->$id = $user->get('name'); } return $data; diff --git a/tests/unit/Espo/ORM/EntityTest.php b/tests/unit/Espo/ORM/EntityTest.php index 417e3415be..005238036d 100644 --- a/tests/unit/Espo/ORM/EntityTest.php +++ b/tests/unit/Espo/ORM/EntityTest.php @@ -27,9 +27,11 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -use Espo\ORM\DB\MysqlMapper; -use Espo\ORM\DB\Query\Mysql as Query; -use Espo\ORM\EntityFactory; +use Espo\ORM\{ + DB\MysqlMapper, + DB\Query\Mysql as Query, + EntityFactory, +}; use Espo\Entities\Job; diff --git a/tests/unit/Espo/ORM/DB/MapperTest.php b/tests/unit/Espo/ORM/MapperTest.php similarity index 85% rename from tests/unit/Espo/ORM/DB/MapperTest.php rename to tests/unit/Espo/ORM/MapperTest.php index 00fecfcd0e..dc970e0952 100644 --- a/tests/unit/Espo/ORM/DB/MapperTest.php +++ b/tests/unit/Espo/ORM/MapperTest.php @@ -27,19 +27,26 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -use Espo\ORM\DB\MysqlMapper; -use Espo\ORM\DB\Query\MysqlQuery as Query; -use Espo\ORM\EntityFactory; -use Espo\ORM\EntityCollection; +use Espo\ORM\{ + Metadata, + EntityManager, + EntityFactory, + CollectionFactory, + EntityCollection, + QueryComposer\MysqlQueryComposer as QueryComposer, + Mapper\MysqlMapper, + QueryParams\Select, +}; -use Espo\Entities\Post; -use Espo\Entities\Comment; -use Espo\Entities\Tag; -use Espo\Entities\Note; -use Espo\Entities\Job; +use Espo\Entities\{ + Post, + Comment, + Tag, + Note, + Job, +}; require_once 'tests/unit/testData/DB/Entities.php'; -require_once 'tests/unit/testData/DB/MockPDO.php'; require_once 'tests/unit/testData/DB/MockDBResult.php'; class DBMapperTest extends \PHPUnit\Framework\TestCase @@ -53,7 +60,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase protected function setUp() : void { - $this->pdo = $this->createMock('MockPDO'); + $this->pdo = $this->getMockBuilder(PDO::class)->disableOriginalConstructor()->getMock(); $this->pdo ->expects($this->any()) ->method('quote') @@ -62,33 +69,49 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase return "'" . $args[0] . "'"; })); - $metadata = $this->getMockBuilder('Espo\\ORM\\Metadata')->disableOriginalConstructor()->getMock(); + $metadata = $this->getMockBuilder(Metadata::class)->disableOriginalConstructor()->getMock(); $metadata ->method('get') ->will($this->returnValue(false)); - $entityManager = $this->getMockBuilder('Espo\\ORM\\EntityManager')->disableOriginalConstructor()->getMock(); + $entityManager = $this->getMockBuilder(EntityManager::class)->disableOriginalConstructor()->getMock(); $entityManager ->method('getMetadata') ->will($this->returnValue($metadata)); - $this->entityFactory = $this->getMockBuilder('Espo\\ORM\\EntityFactory')->disableOriginalConstructor()->getMock(); + $this->entityFactory = $this->getMockBuilder(EntityFactory::class)->disableOriginalConstructor()->getMock(); $this->entityFactory ->expects($this->any()) ->method('create') - ->will($this->returnCallback(function () use ($entityManager) { - $args = func_get_args(); - $className = "\\Espo\\Entities\\" . $args[0]; - return new $className($args[0], [], $entityManager); - })); + ->will($this->returnCallback( + function () use ($entityManager) { + $args = func_get_args(); + $className = "Espo\\Entities\\" . $args[0]; + return new $className($args[0], [], $entityManager); + } + )); + + $entityFactory = $this->entityFactory; + + $this->collectionFactory = $this->getMockBuilder(CollectionFactory::class)->disableOriginalConstructor()->getMock(); + $this->collectionFactory + ->expects($this->any()) + ->method('create') + ->will($this->returnCallback( + function () use ($entityFactory) { + $args = func_get_args(); + $entityType = $args[0] ?? null; + $dataList = $args[1] ?? []; + + return new EntityCollection($dataList, $entityType, $entityFactory); + } + )); $this->metadata = $this->getMockBuilder('Espo\\ORM\\Metadata')->disableOriginalConstructor()->getMock(); - $this->query = new Query($this->pdo, $this->entityFactory, $this->metadata); + $this->query = new QueryComposer($this->pdo, $this->entityFactory, $this->metadata); - $this->db = new MysqlMapper($this->pdo, $this->entityFactory, $this->query, $this->metadata); - - $entityFactory = $this->entityFactory; + $this->db = new MysqlMapper($this->pdo, $this->entityFactory, $this->collectionFactory, $this->query, $this->metadata); $this->post = $entityFactory->create('Post'); $this->comment = $entityFactory->create('Comment'); @@ -120,30 +143,43 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase ->will($this->returnValue($return)); } - public function testSelectById() + public function testSelectOne() { $query = - "SELECT post.id AS `id`, post.name AS `name`, NULLIF(TRIM(CONCAT(IFNULL(createdBy.salutation_name, ''), IFNULL(createdBy.first_name, ''), ' ', IFNULL(createdBy.last_name, ''))), '') AS `createdByName`, post.created_by_id AS `createdById`, post.deleted AS `deleted` ". + "SELECT post.id AS `id`, post.name AS `name`, NULLIF(TRIM(CONCAT(IFNULL(createdBy.salutation_name, ''), " . + "IFNULL(createdBy.first_name, ''), ' ', IFNULL(createdBy.last_name, ''))), '') AS `createdByName`, ". + "post.created_by_id AS `createdById`, post.deleted AS `deleted` ". "FROM `post` ". "LEFT JOIN `user` AS `createdBy` ON post.created_by_id = createdBy.id " . "WHERE post.id = '1' AND post.deleted = 0"; - $return = new MockDBResult(array( - array( + + $return = new MockDBResult([ + [ 'id' => '1', 'name' => 'test', 'deleted' => false, - ), - )); + ], + ]); $this->mockQuery($query, $return); - $this->db->selectById($this->post, '1'); - $this->assertEquals($this->post->id, '1'); + $select = Select::fromRaw([ + 'from' => 'Post', + 'whereClause' => [ + 'id' => '1', + ], + ]); + + $post = $this->db->selectOne($select); + + $this->assertEquals($post->id, '1'); } public function testSelect1() { $query = - "SELECT post.id AS `id`, post.name AS `name`, NULLIF(TRIM(CONCAT(IFNULL(createdBy.salutation_name, ''), IFNULL(createdBy.first_name, ''), ' ', IFNULL(createdBy.last_name, ''))), '') AS `createdByName`, post.created_by_id AS `createdById`, post.deleted AS `deleted` ". + "SELECT post.id AS `id`, post.name AS `name`, NULLIF(TRIM(CONCAT(IFNULL(createdBy.salutation_name, ''), " . + "IFNULL(createdBy.first_name, ''), ' ', IFNULL(createdBy.last_name, ''))), '') AS `createdByName`, " . + "post.created_by_id AS `createdById`, post.deleted AS `deleted` ". "FROM `post` ". "LEFT JOIN `user` AS `createdBy` ON post.created_by_id = createdBy.id " . "JOIN `post_tag` AS `tagsMiddle` ON post.id = tagsMiddle.post_id AND tagsMiddle.deleted = 0 ". @@ -152,39 +188,41 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase "WHERE post.name = 'test_1' AND (post.id = '100' OR post.name LIKE 'test_%') AND tags.name = 'yoTag' AND post.deleted = 0 ". "ORDER BY post.name DESC ". "LIMIT 0, 10"; - $return = new MockDBResult(array( - array( + + $return = new MockDBResult([ + [ 'id' => '2', 'name' => 'test_2', 'deleted' => false, - ), - array( + ], + [ 'id' => '1', 'name' => 'test_1', 'deleted' => false, - ), - )); + ], + ]); $this->mockQuery($query, $return); - $selectParams = array( - 'whereClause' => array( + $selectParams = [ + 'from' => 'Post', + 'whereClause' => [ 'name' => 'test_1', - 'OR' => array( + 'OR' => [ 'id' => '100', 'name*' => 'test_%', - ), + ], 'tags.name' => 'yoTag', - ), + ], 'order' => 'DESC', 'orderBy' => 'name', 'limit' => 10, - 'joins' => array( + 'joins' => [ 'tags', 'comments', - ), - ); - $list = $this->db->select($this->post, $selectParams); + ], + ]; + $list = $this->db->select(Select::fromRaw($selectParams)); $this->assertTrue($list[0] instanceof Post); $this->assertTrue(isset($list[0]->id)); @@ -194,51 +232,65 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase public function testSelectWithSpecifiedParams() { $query = - "SELECT contact.id AS `id`, TRIM(CONCAT(contact.first_name, ' ', contact.last_name)) AS `name`, contact.first_name AS `firstName`, contact.last_name AS `lastName`, contact.deleted AS `deleted` ". + "SELECT contact.id AS `id`, TRIM(CONCAT(contact.first_name, ' ', contact.last_name)) AS `name`, " . + "contact.first_name AS `firstName`, contact.last_name AS `lastName`, contact.deleted AS `deleted` ". "FROM `contact` ". - "WHERE (contact.first_name LIKE 'test%' OR contact.last_name LIKE 'test%' OR CONCAT(contact.first_name, ' ', contact.last_name) LIKE 'test%') AND contact.deleted = 0 ". + "WHERE " . + "(contact.first_name LIKE 'test%' OR contact.last_name LIKE 'test%' OR CONCAT(contact.first_name, ' ', contact.last_name) " . + "LIKE 'test%') ". + "AND contact.deleted = 0 ". "ORDER BY contact.first_name DESC, contact.last_name DESC ". "LIMIT 0, 10"; - $return = new MockDBResult(array( - array( + $return = new MockDBResult([ + [ 'id' => '1', 'name' => 'test', 'deleted' => false, - ), - )); + ], + ]); + $this->mockQuery($query, $return); - $selectParams = array( - 'whereClause' => array( + $selectParams = [ + 'from' => 'Contact', + 'whereClause' => [ 'name*' => 'test%', - ), + ], 'order' => 'DESC', 'orderBy' => 'name', - 'limit' => 10 - ); - $list = $this->db->select($this->contact, $selectParams); + 'limit' => 10, + ]; + + $list = $this->db->select(Select::fromRaw($selectParams)); } public function testJoin() { $query = - "SELECT comment.id AS `id`, comment.post_id AS `postId`, post.name AS `postName`, comment.name AS `name`, comment.deleted AS `deleted` ". + "SELECT comment.id AS `id`, comment.post_id AS `postId`, post.name AS `postName`, comment.name AS `name`, " . + "comment.deleted AS `deleted` ". "FROM `comment` ". "LEFT JOIN `post` AS `post` ON comment.post_id = post.id ". "WHERE comment.deleted = 0"; - $return = new MockDBResult(array( - array( + + $return = new MockDBResult([ + [ 'id' => '11', 'postId' => '1', 'postName' => 'test', 'name' => 'test_comment', 'deleted' => false, - ), - )); + ], + ]); + $this->mockQuery($query, $return); - $list = $this->db->select($this->comment); + $selectParams = [ + 'from' => 'Comment', + ]; + + $list = $this->db->select(Select::fromRaw($selectParams)); $this->assertTrue($list[0] instanceof Comment); $this->assertTrue($list[0]->has('postName')); @@ -252,6 +304,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase "FROM `tag` ". "JOIN `post_tag` AS `postTag` ON postTag.tag_id = tag.id AND postTag.post_id = '1' AND postTag.deleted = 0 ". "WHERE tag.deleted = 0"; + $return = new MockDBResult([ [ 'id' => '1', @@ -259,8 +312,10 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase 'deleted' => false, ], ]); + $this->mockQuery($query, $return); $this->post->id = '1'; + $list = $this->db->selectRelated($this->post, 'tags'); $this->assertTrue($list[0] instanceof Tag); @@ -284,11 +339,15 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase $this->mockQuery($query, $return); $this->account->id = '1'; - $list = $this->db->selectRelated($this->account, 'teams', [ - 'additionalColumns' => [ - 'teamId' => 'stub', + $select = Select::fromRaw([ + 'from' => 'Team', + 'select' => [ + '*', + ['entityTeam.teamId', 'stub'], ], ]); + + $list = $this->db->selectRelated($this->account, 'teams', $select); } public function testSelectRelatedHasChildren() @@ -298,13 +357,15 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase "note.id AS `id`, note.name AS `name`, note.parent_id AS `parentId`, note.parent_type AS `parentType`, note.deleted AS `deleted` ". "FROM `note` ". "WHERE note.parent_id = '1' AND note.parent_type = 'Post' AND note.deleted = 0"; - $return = new MockDBResult(array( - array( + + $return = new MockDBResult([ + [ 'id' => '1', 'name' => 'test', 'deleted' => false, - ), - )); + ], + ]); + $this->mockQuery($query, $return); $this->post->id = '1'; $list = $this->db->selectRelated($this->post, 'notes'); @@ -343,7 +404,6 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase $this->assertEquals($post->get('name'), 'test'); } - public function testCountRelated() { $query = @@ -351,14 +411,17 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase "FROM `tag` ". "JOIN `post_tag` AS `postTag` ON postTag.tag_id = tag.id AND postTag.post_id = '1' AND postTag.deleted = 0 ". "WHERE tag.deleted = 0"; + $return = new MockDBResult([ [ 'value' => 1, ], ]); + $this->mockQuery($query, $return); $this->post->id = '1'; + $count = $this->db->countRelated($this->post, 'tags'); $this->assertEquals($count, 1); @@ -368,6 +431,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase { $query = "INSERT INTO `post` (`id`, `name`) VALUES ('1', 'test')"; $return = true; + $this->mockQuery($query, $return); $this->post->reset(); @@ -509,6 +573,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase $this->note->id = 'noteId'; $this->post->id = 'postId'; + $this->db->unrelate($this->note, 'parent', $this->post); } @@ -533,6 +598,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase $this->mockQuery($query, true); $this->post->id = 'postId'; + $this->db->unrelateById($this->post, 'comments', 'commentId'); } @@ -545,6 +611,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase $this->mockQuery($query, true); $this->post->id = 'postId'; + $this->db->unrelateAll($this->post, 'comments'); } @@ -571,6 +638,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase $this->mockQuery($query, true); $this->post->id = 'postId'; + $this->db->unrelateById($this->post, 'notes', 'noteId'); } @@ -583,6 +651,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase $this->mockQuery($query, true); $this->post->id = 'postId'; + $this->db->unrelateAll($this->post, 'notes'); } @@ -593,6 +662,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase $this->mockQuery($query, $return); $this->post->id = '1'; + $this->db->unrelateById($this->post, 'tags', '100'); } @@ -603,6 +673,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase $this->mockQuery($query, $return); $this->post->id = '1'; + $this->db->unrelateAll($this->post, 'tags'); } @@ -611,10 +682,11 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase $query = "UPDATE `entity_team` SET entity_team.deleted = 1 ". "WHERE entity_team.entity_id = '1' AND entity_team.team_id = '100' AND entity_team.entity_type = 'Account'"; - $return = true; - $this->mockQuery($query, $return); + + $this->mockQuery($query, true); $this->account->id = '1'; + $this->db->unrelateById($this->account, 'teams', '100'); } @@ -624,8 +696,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase "UPDATE `entity_team` SET entity_team.deleted = 1 ". "WHERE entity_team.entity_id = '1' AND entity_team.entity_type = 'Account'"; - $return = true; - $this->mockQuery($query, $return); + $this->mockQuery($query, true); $this->account->id = '1'; $this->db->unrelateAll($this->account, 'teams'); @@ -634,8 +705,8 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase public function testUnrelateManyToMany() { $query = "UPDATE `post_tag` SET post_tag.deleted = 1 WHERE post_tag.post_id = 'postId' AND post_tag.tag_id = 'tagId'"; - $return = true; - $this->mockQuery($query, $return); + + $this->mockQuery($query, true); $this->post->id = 'postId'; $this->tag->id = 'tagId'; @@ -802,7 +873,8 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase "WHERE post_tag.post_id = 'postId' AND post_tag.tag_id = 'tagId'"; $query3 = - "UPDATE `post_tag` SET post_tag.deleted = 0, post_tag.role = 'Test' WHERE post_tag.post_id = 'postId' AND post_tag.tag_id = 'tagId'"; + "UPDATE `post_tag` SET post_tag.deleted = 0, post_tag.role = 'Test' " . + "WHERE post_tag.post_id = 'postId' AND post_tag.tag_id = 'tagId'"; $ps = $this->createMock(\PDOStatement::class); $ps->expects($this->exactly(1)) @@ -896,7 +968,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase $this->assertEquals('test', $result); } - public function testUpdateRelation() + public function testUpdateRelationColumns() { $this->post->id = 'postId'; $this->tag->id = 'tagId'; @@ -907,7 +979,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase $this->mockQuery($query); - $this->db->updateRelation($this->post, 'tags', 'tagId', [ + $this->db->updateRelationColumns($this->post, 'tags', 'tagId', [ 'role' => 'test' ]); } @@ -922,7 +994,7 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase ]); $this->mockQuery($query, $return); - $value = $this->db->max($this->post, array(), 'id', true); + $value = $this->db->max(Select::fromRaw(['from' => 'Post']), 'id'); $this->assertEquals($value, 10); } @@ -938,11 +1010,14 @@ class DBMapperTest extends \PHPUnit\Framework\TestCase $this->post->id = '1'; - $this->db->massRelate($this->post, 'tags', [ + $select = Select::fromRaw([ + 'from' => 'Tag', 'whereClause' => [ 'name' => 'test', ], ]); + + $this->db->massRelate($this->post, 'tags', $select); } public function testDeleteFromDb1() diff --git a/tests/unit/Espo/ORM/MetadataTest.php b/tests/unit/Espo/ORM/MetadataTest.php new file mode 100644 index 0000000000..2c8a374ae0 --- /dev/null +++ b/tests/unit/Espo/ORM/MetadataTest.php @@ -0,0 +1,99 @@ + [], + ]); + + $this->assertTrue($metadata->has('Test')); + } + + public function testHas2() + { + $metadata = new Metadata([ + 'Test' => [], + ]); + + $this->assertFalse($metadata->has('Hello')); + } + + public function testGet1() + { + $metadata = new Metadata([ + 'Test' => [ + 'indexes' => [], + ], + ]); + + $this->assertEquals([], $metadata->get('Test', 'indexes')); + } + + public function testGet2() + { + $metadata = new Metadata([ + 'Test' => [ + 'relations' => [ + 'test' => [ + 'type' => 'hasMany', + ], + ], + ], + ]); + + $this->assertEquals('hasMany', $metadata->get('Test', 'relations.test.type')); + } + + public function testGet3() + { + $metadata = new Metadata([ + 'Test' => [ + 'relations' => [ + 'test' => [ + 'type' => 'hasMany', + ], + ], + ], + ]); + + $this->assertEquals('hasMany', $metadata->get('Test', ['relations', 'test', 'type'])); + } +} diff --git a/tests/unit/Espo/ORM/DB/QueryTest.php b/tests/unit/Espo/ORM/MysqlQueryComposerTest.php similarity index 79% rename from tests/unit/Espo/ORM/DB/QueryTest.php rename to tests/unit/Espo/ORM/MysqlQueryComposerTest.php index a2992dcea8..4d522ffeca 100644 --- a/tests/unit/Espo/ORM/DB/QueryTest.php +++ b/tests/unit/Espo/ORM/MysqlQueryComposerTest.php @@ -27,22 +27,35 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -use Espo\ORM\DB\Query\MysqlQuery as Query; -use Espo\ORM\EntityFactory; -use Espo\ORM\Metadata; -use Espo\Core\ORM\EntityManager; +use Espo\ORM\{ + EntityFactory, + Metadata, + QueryComposer\MysqlQueryComposer as QueryComposer, + QueryBuilder, + EntityManager, +}; -use Espo\Entities\Post; -use Espo\Entities\Comment; -use Espo\Entities\Tag; -use Espo\Entities\Note; +use Espo\ORM\QueryParams\{ + Select, + Insert, + Update, + Delete, +}; + + +use Espo\Entities\{ + Post, + Comment, + Tag, + Note, +}; require_once 'tests/unit/testData/DB/Entities.php'; require_once 'tests/unit/testData/DB/MockPDO.php'; require_once 'tests/unit/testData/DB/MockDBResult.php'; -class QueryTest extends \PHPUnit\Framework\TestCase +class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase { protected $query; @@ -52,6 +65,8 @@ class QueryTest extends \PHPUnit\Framework\TestCase protected function setUp() : void { + $this->queryBuilder = new QueryBuilder(); + $this->pdo = $this->createMock('MockPDO'); $this->pdo ->expects($this->any()) @@ -80,7 +95,7 @@ class QueryTest extends \PHPUnit\Framework\TestCase $this->getMockBuilder('Espo\\ORM\\Metadata')->disableOriginalConstructor()->getMock(); - $this->query = new Query($this->pdo, $this->entityFactory, $this->metadata); + $this->query = new QueryComposer($this->pdo, $this->entityFactory, $this->metadata); $entityFactory = $this->entityFactory; @@ -106,11 +121,12 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testDelete1() { - $sql = $this->query->createDeleteQuery('Account', [ + $sql = $this->query->compose(Delete::fromRaw([ + 'from' => 'Account', 'whereClause' => [ 'name' => 'test', ], - ]); + ])); $expectedSql = "DELETE FROM `account` " . @@ -121,13 +137,14 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testDeleteWithLimit() { - $sql = $this->query->createDeleteQuery('Account', [ + $sql = $this->query->compose(Delete::fromRaw([ + 'from' => 'Account', 'whereClause' => [ 'name' => 'test', ], 'orderBy' => 'name', 'limit' => 1, - ]); + ])); $expectedSql = "DELETE FROM `account` " . @@ -140,12 +157,13 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testDeleteWithDeleted() { - $sql = $this->query->createDeleteQuery('Account', [ + $sql = $this->query->compose(Delete::fromRaw([ + 'from' => 'Account', 'whereClause' => [ 'name' => 'test', 'deleted' => true, ], - ]); + ])); $expectedSql = "DELETE FROM `account` " . @@ -156,15 +174,16 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testUpdateQuery1() { - $sql = $this->query->createUpdateQuery('Account', [ + $sql = $this->query->compose(Update::fromRaw([ + 'from' => 'Account', 'whereClause' => [ 'name' => 'test', ], - 'update' => [ + 'set' => [ 'deleted' => false, 'name' => 'hello', ], - ]); + ])); $expectedSql = "UPDATE `account` " . @@ -176,15 +195,16 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testUpdateQueryWithJoin() { - $sql = $this->query->createUpdateQuery('Comment', [ + $sql = $this->query->compose(Update::fromRaw([ + 'from' => 'Comment', 'whereClause' => [ 'name' => 'test', ], 'joins' => ['post'], - 'update' => [ + 'set' => [ 'name:' => 'post.name', ], - ]); + ])); $expectedSql = "UPDATE `comment` " . @@ -197,16 +217,17 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testUpdateQueryWithOrder() { - $sql = $this->query->createUpdateQuery('Account', [ + $sql = $this->query->compose(Update::fromRaw([ + 'from' => 'Account', 'whereClause' => [ 'name' => 'test', ], 'orderBy' => 'name', - 'update' => [ + 'set' => [ 'deleted' => false, 'name' => 'hello', ] - ]); + ])); $expectedSql = "UPDATE `account` " . @@ -219,17 +240,18 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testUpdateQueryWithLimit() { - $sql = $this->query->createUpdateQuery('Account', [ + $sql = $this->query->compose(Update::fromRaw([ + 'from' => 'Account', 'whereClause' => [ 'name' => 'test', ], 'orderBy' => 'name', 'limit' => 1, - 'update' => [ + 'set' => [ 'deleted' => false, 'name' => 'hello', ], - ]); + ])); $expectedSql = "UPDATE `account` " . @@ -243,13 +265,14 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testInsertQuery1() { - $sql = $this->query->createInsertQuery('Account', [ + $sql = $this->query->compose(Insert::fromRaw([ + 'into' => 'Account', 'columns' => ['id', 'name'], 'values' => [ 'id' => '1', 'name' => 'hello', ], - ]); + ])); $expectedSql = "INSERT INTO `account` (`id`, `name`) VALUES ('1', 'hello')"; @@ -259,7 +282,8 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testInsertQuery2() { - $sql = $this->query->createInsertQuery('Account', [ + $sql = $this->query->compose(Insert::fromRaw([ + 'into' => 'Account', 'columns' => ['id', 'name'], 'values' => [ [ @@ -271,7 +295,7 @@ class QueryTest extends \PHPUnit\Framework\TestCase 'name' => 'test', ], ], - ]); + ])); $expectedSql = "INSERT INTO `account` (`id`, `name`) VALUES ('1', 'hello'), ('2', 'test')"; @@ -281,17 +305,18 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testInsertUpdate() { - $sql = $this->query->createInsertQuery('Account', [ + $sql = $this->query->compose(Insert::fromRaw([ + 'into' => 'Account', 'columns' => ['id', 'name'], 'values' => [ 'id' => '1', 'name' => 'hello', ], - 'update' => [ + 'updateSet' => [ 'deleted' => false, 'name' => 'test' ], - ]); + ])); $expectedSql = "INSERT INTO `account` (`id`, `name`) VALUES ('1', 'hello') ON DUPLICATE KEY UPDATE `deleted` = 0, `name` = 'test'"; @@ -299,14 +324,19 @@ class QueryTest extends \PHPUnit\Framework\TestCase $this->assertEquals($expectedSql, $sql); } - public function testSelectAllColumns() + public function testSelectAllColumns1() { - $sql = $this->query->createSelectQuery('Account', [ - 'orderBy' => 'name', - 'order' => 'ASC', - 'offset' => 10, - 'limit' => 20 - ]); + $select = $this->queryBuilder + ->select() + ->from('Account') + ->order('name', 'ASC') + ->where([ + 'deleted' => false, + ]) + ->limit(10, 20) + ->build(); + + $sql = $this->query->compose($select); $expectedSql = "SELECT account.id AS `id`, account.name AS `name`, account.deleted AS `deleted` FROM `account` " . @@ -315,15 +345,59 @@ class QueryTest extends \PHPUnit\Framework\TestCase $this->assertEquals($expectedSql, $sql); } + public function testSelectAllColumns2() + { + $expectedSql = + "SELECT account.id AS `id`, account.name AS `name`, account.deleted AS `deleted` FROM `account` " . + "WHERE account.deleted = 0"; + + $select = $this->queryBuilder + ->select() + ->from('Account') + ->select(['*']) + ->where([ + 'deleted' => false, + ]) + ->build(); + + $sql = $this->query->compose($select); + + $this->assertEquals($expectedSql, $sql); + } + + public function testSelectAllColumnsWithExtra() + { + $expectedSql = + "SELECT account.id AS `id`, account.name AS `name`, account.deleted AS `deleted`, LOWER(account.name) AS `lowerName` " . + "FROM `account` " . + "WHERE account.deleted = 0"; + + $select = $this->queryBuilder + ->select() + ->from('Account') + ->select(['*', ['LOWER:name', 'lowerName']]) + ->where([ + 'deleted' => false, + ]) + ->build(); + + $sql = $this->query->compose($select); + + $this->assertEquals($expectedSql, $sql); + } + public function testSelectSkipTextColumns() { - $sql = $this->query->createSelectQuery('Article', [ - 'orderBy' => 'name', - 'order' => 'ASC', - 'offset' => 10, - 'limit' => 20, - 'skipTextColumns' => true - ]); + $sql = $this->query->compose( + Select::fromRaw([ + 'from' => 'Article', + 'orderBy' => 'name', + 'order' => 'ASC', + 'offset' => 10, + 'limit' => 20, + 'skipTextColumns' => true, + ]) + ); $expectedSql = "SELECT article.id AS `id`, article.name AS `name`, article.deleted AS `deleted` FROM `article` " . @@ -334,8 +408,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testSelectWithBelongsToJoin() { - $sql = $this->query->createSelectQuery('Comment', [ - ]); + $sql = $this->query->compose( + Select::fromRaw([ + 'from' => 'Comment', + ]) + ); $expectedSql = "SELECT comment.id AS `id`, comment.post_id AS `postId`, post.name AS `postName`, comment.name AS `name`, comment.deleted AS `deleted` FROM `comment` " . @@ -347,18 +424,24 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testSelectWithSpecifiedColumns() { - $sql = $this->query->createSelectQuery('Comment', [ - 'select' => ['id', 'name'] - ]); + $sql = $this->query->compose( + Select::fromRaw([ + 'from' => 'Comment', + 'select' => ['id', 'name'] + ]) + ); + $expectedSql = "SELECT comment.id AS `id`, comment.name AS `name` FROM `comment` " . "WHERE comment.deleted = 0"; $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Comment', array( - 'select' => array('id', 'name', 'postName') - )); + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', + 'select' => ['id', 'name', 'postName'], + ])); + $expectedSql = "SELECT comment.id AS `id`, comment.name AS `name`, post.name AS `postName` FROM `comment` " . "LEFT JOIN `post` AS `post` ON comment.post_id = post.id " . @@ -366,10 +449,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Comment', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => array('id', 'name', 'postName'), - 'leftJoins' => array('post') - )); + 'leftJoins' => array('post'), + ])); $expectedSql = "SELECT comment.id AS `id`, comment.name AS `name`, post.name AS `postName` FROM `comment` " . "LEFT JOIN `post` AS `post` ON comment.post_id = post.id " . @@ -377,10 +461,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Comment', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => array('id', 'name'), 'leftJoins' => array('post') - )); + ])); $expectedSql = "SELECT comment.id AS `id`, comment.name AS `name` FROM `comment` " . "LEFT JOIN `post` AS `post` ON comment.post_id = post.id " . @@ -391,20 +476,22 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testSelectUseIndex() { - $sql = $this->query->createSelectQuery('Account', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Account', 'select' => ['id', 'name'], 'useIndex' => 'name', - ]); + ])); $expectedSql = "SELECT account.id AS `id`, account.name AS `name` FROM `account` USE INDEX (`IDX_NAME`) " . "WHERE account.deleted = 0"; $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Account', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Account', 'select' => ['id', 'name'], 'useIndex' => ['name'], - ]); + ])); $expectedSql = "SELECT account.id AS `id`, account.name AS `name` FROM `account` USE INDEX (`IDX_NAME`) " . "WHERE account.deleted = 0"; @@ -414,11 +501,12 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testWithSpecifiedFunction() { - $sql = $this->query->createSelectQuery('Comment', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => array('id', 'postId', 'post.name', 'COUNT:id'), 'leftJoins' => array('post'), 'groupBy' => array('postId', 'post.name') - )); + ])); $expectedSql = "SELECT comment.id AS `id`, comment.post_id AS `postId`, post.name AS `post.name`, COUNT(comment.id) AS `COUNT:id` FROM `comment` " . "LEFT JOIN `post` AS `post` ON comment.post_id = post.id " . @@ -427,11 +515,12 @@ class QueryTest extends \PHPUnit\Framework\TestCase $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Comment', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => array('id', 'COUNT:id', 'MONTH:post.createdAt'), 'leftJoins' => array('post'), 'groupBy' => array('MONTH:post.createdAt') - )); + ])); $expectedSql = "SELECT comment.id AS `id`, COUNT(comment.id) AS `COUNT:id`, DATE_FORMAT(post.created_at, '%Y-%m') AS `MONTH:post.createdAt` FROM `comment` " . "LEFT JOIN `post` AS `post` ON comment.post_id = post.id " . @@ -442,10 +531,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testSelectWithJoinChildren() { - $sql = $this->query->createSelectQuery('Post', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Post', 'select' => ['id', 'name'], 'leftJoins' => [['notes', 'notesLeft']] - )); + ])); $expectedSql = "SELECT post.id AS `id`, post.name AS `name` FROM `post` " . @@ -457,10 +547,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testJoinConditions1() { - $sql = $this->query->createSelectQuery('Post', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Post', 'select' => ['id', 'name'], 'leftJoins' => [['notes', 'notesLeft', ['notesLeft.name!=' => null]]] - ]); + ])); $expectedSql = "SELECT post.id AS `id`, post.name AS `name` FROM `post` " . @@ -473,10 +564,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testJoinConditions2() { - $sql = $this->query->createSelectQuery('Post', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Post', 'select' => ['id', 'name'], 'leftJoins' => [['notes', 'notesLeft', ['notesLeft.name=:' => 'post.name']]] - ]); + ])); $expectedSql = "SELECT post.id AS `id`, post.name AS `name` FROM `post` " . @@ -489,7 +581,8 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testJoinConditions3() { - $sql = $this->query->createSelectQuery('Note', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Note', 'select' => ['id'], 'leftJoins' => [['post', 'post', [ 'OR' => [ @@ -498,7 +591,7 @@ class QueryTest extends \PHPUnit\Framework\TestCase ] ]]], 'withDeleted' => true, - ]); + ])); $expectedSql = "SELECT note.id AS `id` FROM `note` LEFT JOIN `post` AS `post` ON (post.name = 'test' OR post.name IS NULL)"; @@ -507,7 +600,8 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testJoinConditions4() { - $sql = $this->query->createSelectQuery('Note', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Note', 'select' => ['id'], 'leftJoins' => [['post', 'post', [ 'name' => null, @@ -517,7 +611,7 @@ class QueryTest extends \PHPUnit\Framework\TestCase ] ]]], 'withDeleted' => true, - ]); + ])); $expectedSql = "SELECT note.id AS `id` FROM `note` LEFT JOIN `post` AS `post` ON post.name IS NULL AND (post.name = 'test' OR post.name IS NULL)"; @@ -526,10 +620,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testJoinTable() { - $sql = $this->query->createSelectQuery('Post', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Post', 'select' => ['id', 'name'], 'leftJoins' => [['NoteTable', 'note', ['note.parentId=:' => 'post.id', 'note.parentType' => 'Post']]] - ]); + ])); $expectedSql = "SELECT post.id AS `id`, post.name AS `name` FROM `post` " . @@ -542,10 +637,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testJoinOnlyMiddle() { - $sql = $this->query->createSelectQuery('Post', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Post', 'select' => ['id'], 'leftJoins' => [['tags', null, null, ['onlyMiddle' => true]]] - ]); + ])); $expectedSql = "SELECT post.id AS `id` FROM `post` " . @@ -557,12 +653,13 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testWhereNotValue1() { - $sql = $this->query->createSelectQuery('Post', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Post', 'select' => ['id', 'name'], 'whereClause' => [ 'name!=:' => 'post.id' ] - ]); + ])); $expectedSql = "SELECT post.id AS `id`, post.name AS `name` FROM `post` " . @@ -573,13 +670,14 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testWhereNotValue2() { - $sql = $this->query->createSelectQuery('Post', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Post', 'select' => ['id', 'name'], 'whereClause' => [ 'name:' => null ], 'withDeleted' => true - ]); + ])); $expectedSql = "SELECT post.id AS `id`, post.name AS `name` FROM `post` " . @@ -590,7 +688,8 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testSelectWithSubquery() { - $sql = $this->query->createSelectQuery('Post', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Post', 'select' => ['id', 'name'], 'whereClause' => array( 'post.id=s' => array( @@ -603,12 +702,13 @@ class QueryTest extends \PHPUnit\Framework\TestCase ) ) ) - )); + ])); $expectedSql = "SELECT post.id AS `id`, post.name AS `name` FROM `post` WHERE post.id IN (SELECT post.id AS `id` FROM `post` WHERE post.name = 'test' AND post.deleted = 0) AND post.deleted = 0"; $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Post', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Post', 'select' => ['id', 'name'], 'whereClause' => array( 'post.id!=s' => array( @@ -621,13 +721,14 @@ class QueryTest extends \PHPUnit\Framework\TestCase ) ) ) - )); + ])); $expectedSql = "SELECT post.id AS `id`, post.name AS `name` FROM `post` WHERE post.id NOT IN (SELECT post.id AS `id` FROM `post` WHERE post.name = 'test' AND post.deleted = 0) AND post.deleted = 0"; $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Post', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Post', 'select' => ['id', 'name'], 'whereClause' => array( 'NOT'=> array( @@ -635,7 +736,7 @@ class QueryTest extends \PHPUnit\Framework\TestCase 'post.createdById' => '1' ) ) - )); + ])); $expectedSql = "SELECT post.id AS `id`, post.name AS `name` FROM `post` WHERE post.id NOT IN (SELECT post.id AS `id` FROM `post` WHERE post.name = 'test' AND post.created_by_id = '1' AND post.deleted = 0) AND post.deleted = 0"; $this->assertEquals($expectedSql, $sql); @@ -643,10 +744,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testGroupBy() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['COUNT:id', 'QUARTER:comment.createdAt'], 'groupBy' => ['QUARTER:comment.createdAt'] - ]); + ])); $expectedSql = "SELECT COUNT(comment.id) AS `COUNT:id`, CONCAT(YEAR(comment.created_at), '_', QUARTER(comment.created_at)) AS `QUARTER:comment.createdAt` FROM `comment` " . "WHERE comment.deleted = 0 " . @@ -654,10 +756,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['COUNT:id', 'YEAR_5:comment.createdAt'], 'groupBy' => ['YEAR_5:comment.createdAt'] - ]); + ])); $expectedSql = "SELECT COUNT(comment.id) AS `COUNT:id`, CASE WHEN MONTH(comment.created_at) >= 6 THEN YEAR(comment.created_at) ELSE YEAR(comment.created_at) - 1 END AS `YEAR_5:comment.createdAt` FROM `comment` " . "WHERE comment.deleted = 0 " . @@ -665,10 +768,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['COUNT:id', 'QUARTER_4:comment.createdAt'], 'groupBy' => ['QUARTER_4:comment.createdAt'] - ]); + ])); $expectedSql = "SELECT COUNT(comment.id) AS `COUNT:id`, CASE WHEN MONTH(comment.created_at) >= 5 THEN CONCAT(YEAR(comment.created_at), '_', FLOOR((MONTH(comment.created_at) - 5) / 3) + 1) ELSE CONCAT(YEAR(comment.created_at) - 1, '_', CEIL((MONTH(comment.created_at) + 7) / 3)) END AS `QUARTER_4:comment.createdAt` FROM `comment` " . @@ -679,12 +783,13 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testOrderBy() { - $sql = $this->query->createSelectQuery('Comment', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => array('COUNT:id', 'YEAR:post.createdAt'), 'leftJoins' => array('post'), 'groupBy' => array('YEAR:post.createdAt'), 'orderBy' => 2 - )); + ])); $expectedSql = "SELECT COUNT(comment.id) AS `COUNT:id`, YEAR(post.created_at) AS `YEAR:post.createdAt` FROM `comment` " . "LEFT JOIN `post` AS `post` ON comment.post_id = post.id " . @@ -693,12 +798,13 @@ class QueryTest extends \PHPUnit\Framework\TestCase "ORDER BY 2 ASC"; $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Comment', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => array('COUNT:id', 'post.name'), 'leftJoins' => array('post'), 'groupBy' => array('post.name'), 'orderBy' => 'LIST:post.name:Test,Hello', - )); + ])); $expectedSql = "SELECT COUNT(comment.id) AS `COUNT:id`, post.name AS `post.name` FROM `comment` " . @@ -708,7 +814,8 @@ class QueryTest extends \PHPUnit\Framework\TestCase "ORDER BY FIELD(post.name, 'Hello', 'Test') DESC"; $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Comment', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => array('COUNT:id', 'YEAR:post.createdAt', 'post.name'), 'leftJoins' => array('post'), 'groupBy' => array('YEAR:post.createdAt', 'post.name'), @@ -716,7 +823,7 @@ class QueryTest extends \PHPUnit\Framework\TestCase array(2, 'DESC'), array('LIST:post.name:Test,Hello') ) - )); + ])); $expectedSql = "SELECT COUNT(comment.id) AS `COUNT:id`, YEAR(post.created_at) AS `YEAR:post.createdAt`, post.name AS `post.name` FROM `comment` " . "LEFT JOIN `post` AS `post` ON comment.post_id = post.id " . @@ -728,14 +835,15 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testForeign() { - $sql = $this->query->createSelectQuery('Comment', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => array('COUNT:comment.id', 'postId', 'postName'), 'leftJoins' => array('post'), 'groupBy' => array('postId'), 'whereClause' => array( 'post.createdById' => 'id_1' ), - )); + ])); $expectedSql = "SELECT COUNT(comment.id) AS `COUNT:comment.id`, comment.post_id AS `postId`, post.name AS `postName` FROM `comment` " . "LEFT JOIN `post` AS `post` ON comment.post_id = post.id " . @@ -746,46 +854,50 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testInArray() { - $sql = $this->query->createSelectQuery('Comment', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id'], 'whereClause' => array( 'id' => ['id_1'] ), - )); + ])); $expectedSql = "SELECT comment.id AS `id` FROM `comment` " . "WHERE comment.id IN ('id_1') AND comment.deleted = 0"; $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Comment', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id'], 'whereClause' => array( 'id!=' => ['id_1'] ), - )); + ])); $expectedSql = "SELECT comment.id AS `id` FROM `comment` " . "WHERE comment.id NOT IN ('id_1') AND comment.deleted = 0"; $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Comment', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id'], 'whereClause' => array( 'id' => [] ), - )); + ])); $expectedSql = "SELECT comment.id AS `id` FROM `comment` " . "WHERE 0 AND comment.deleted = 0"; $this->assertEquals($expectedSql, $sql); - $sql = $this->query->createSelectQuery('Comment', array( + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id'], 'whereClause' => array( 'name' => 'Test', 'id!=' => [] ), - )); + ])); $expectedSql = "SELECT comment.id AS `id` FROM `comment` " . "WHERE comment.name = 'Test' AND 1 AND comment.deleted = 0"; @@ -794,12 +906,13 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction1() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id'], 'whereClause' => [ 'MONTH_NUMBER:comment.created_at' => 2 ] - ]); + ])); $expectedSql = "SELECT comment.id AS `id` FROM `comment` " . "WHERE MONTH(comment.created_at) = '2' AND comment.deleted = 0"; @@ -808,12 +921,13 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction2() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id'], 'whereClause' => [ 'WEEK_NUMBER_1:createdAt' => 2 ] - ]); + ])); $expectedSql = "SELECT comment.id AS `id` FROM `comment` " . "WHERE WEEK(comment.created_at, 3) = '2' AND comment.deleted = 0"; @@ -822,12 +936,13 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction3() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id'], 'whereClause' => [ 'MONTH_NUMBER:(comment.created_at)' => 2 ] - ]); + ])); $expectedSql = "SELECT comment.id AS `id` FROM `comment` " . "WHERE MONTH(comment.created_at) = '2' AND comment.deleted = 0"; @@ -836,12 +951,13 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction4() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id'], 'whereClause' => [ "CONCAT:(MONTH:comment.created_at,' ',CONCAT:(comment.name,'+'))" => 'Test Hello' ] - ]); + ])); $expectedSql = "SELECT comment.id AS `id` FROM `comment` " . "WHERE CONCAT(DATE_FORMAT(comment.created_at, '%Y-%m'), ' ', CONCAT(comment.name, '+')) = 'Test Hello' AND comment.deleted = 0"; @@ -850,11 +966,12 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction5() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id', ['FLOOR:3.5', 'FLOOR:3.5']], 'whereClause' => [ ] - ]); + ])); $expectedSql = "SELECT comment.id AS `id`, FLOOR('3.5') AS `FLOOR:3.5` FROM `comment` " . "WHERE comment.deleted = 0"; @@ -863,10 +980,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction6() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id', ['ROUND:3.5,1', 'ROUND:3.5,1']], 'whereClause' => [] - ]); + ])); $expectedSql = "SELECT comment.id AS `id`, ROUND('3.5', '1') AS `ROUND:3.5,1` FROM `comment` " . "WHERE comment.deleted = 0"; @@ -875,10 +993,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction7() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id', 'ROUND:3.5,1'], 'whereClause' => [] - ]); + ])); $expectedSql = "SELECT comment.id AS `id`, ROUND('3.5', '1') AS `ROUND:3.5,1` FROM `comment` " . "WHERE comment.deleted = 0"; @@ -887,9 +1006,10 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction8() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id', ["CONCAT:(',test',\"+\",'\"', \"'\")", 'value']] - ]); + ])); $expectedSql = "SELECT comment.id AS `id`, CONCAT(',test', '+', '\"', ''') AS `value` FROM `comment` " . "WHERE comment.deleted = 0"; @@ -898,9 +1018,10 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction9() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id', ["COALESCE:(name,FALSE,true,null)", 'value']] - ]); + ])); $expectedSql = "SELECT comment.id AS `id`, COALESCE(comment.name, FALSE, TRUE, NULL) AS `value` FROM `comment` " . "WHERE comment.deleted = 0"; @@ -909,9 +1030,10 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction10() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id', ["IF:(LIKE:(name,'%test%'),'1','0')", 'value']] - ]); + ])); $expectedSql = "SELECT comment.id AS `id`, IF(comment.name LIKE '%test%', '1', '0') AS `value` FROM `comment` " . "WHERE comment.deleted = 0"; @@ -920,10 +1042,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction11() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => [["IS_NULL:(name)", 'value1'], ["IS_NOT_NULL:(name)", 'value2']], 'withDeleted' => true - ]); + ])); $expectedSql = "SELECT comment.name IS NULL AS `value1`, comment.name IS NOT NULL AS `value2` FROM `comment`"; $this->assertEquals($expectedSql, $sql); @@ -931,10 +1054,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction12() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ["IF:(OR:('1','0'),'1',' ')"], 'withDeleted' => true - ]); + ])); $expectedSql = "SELECT IF('1' OR '0', '1', ' ') AS `IF:(OR:('1','0'),'1',' ')` FROM `comment`"; $this->assertEquals($expectedSql, $sql); @@ -942,10 +1066,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction13() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ["IN:(name,'1','0')"], 'withDeleted' => true - ]); + ])); $expectedSql = "SELECT comment.name IN ('1', '0') AS `IN:(name,'1','0')` FROM `comment`"; $this->assertEquals($expectedSql, $sql); @@ -953,10 +1078,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction14() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ["NOT:(name)"], 'withDeleted' => true - ]); + ])); $expectedSql = "SELECT NOT comment.name AS `NOT:(name)` FROM `comment`"; $this->assertEquals($expectedSql, $sql); @@ -964,10 +1090,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction15() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ["MUL:(2,2.5,SUB:(3,1))"], 'withDeleted' => true - ]); + ])); $expectedSql = "SELECT ('2' * '2.5' * ('3' - '1')) AS `MUL:(2,2.5,SUB:(3,1))` FROM `comment`"; $this->assertEquals($expectedSql, $sql); @@ -975,10 +1102,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction16() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ["NOW:()"], 'withDeleted' => true - ]); + ])); $expectedSql = "SELECT NOW() AS `NOW:()` FROM `comment`"; $this->assertEquals($expectedSql, $sql); @@ -986,10 +1114,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction17() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => [["TIMESTAMPDIFF_YEAR:('2016-10-10', '2018-10-10')", 'test']], 'withDeleted' => true - ]); + ])); $expectedSql = "SELECT TIMESTAMPDIFF(YEAR, '2016-10-10', '2018-10-10') AS `test` FROM `comment`"; $this->assertEquals($expectedSql, $sql); @@ -997,10 +1126,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunction18() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => [["IFNULL:(name, '')", 'test']], 'withDeleted' => true - ]); + ])); $expectedSql = "SELECT IFNULL(comment.name, '') AS `test` FROM `comment`"; $this->assertEquals($expectedSql, $sql); @@ -1008,10 +1138,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunctionTZ1() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id', "MONTH_NUMBER:TZ:(comment.created_at,-3.5)"], 'whereClause' => [] - ]); + ])); $expectedSql = "SELECT comment.id AS `id`, MONTH(CONVERT_TZ(comment.created_at, '+00:00', '-03:30')) AS `MONTH_NUMBER:TZ:(comment.created_at,-3.5)` FROM `comment` " . "WHERE comment.deleted = 0"; @@ -1020,10 +1151,11 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testFunctionTZ2() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id', "MONTH_NUMBER:TZ:(comment.created_at,0)"], 'whereClause' => [] - ]); + ])); $expectedSql = "SELECT comment.id AS `id`, MONTH(CONVERT_TZ(comment.created_at, '+00:00', '+00:00')) AS `MONTH_NUMBER:TZ:(comment.created_at,0)` FROM `comment` " . "WHERE comment.deleted = 0"; @@ -1032,7 +1164,8 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testHaving() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['COUNT:comment.id', 'postId', 'postName'], 'leftJoins' => ['post'], 'groupBy' => ['postId'], @@ -1042,7 +1175,7 @@ class QueryTest extends \PHPUnit\Framework\TestCase 'havingClause' => [ 'COUNT:comment.id>' => 1 ] - ]); + ])); $expectedSql = "SELECT COUNT(comment.id) AS `COUNT:comment.id`, comment.post_id AS `postId`, post.name AS `postName` " . @@ -1055,13 +1188,14 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testWhere1() { - $sql = $this->query->createSelectQuery('Comment', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Comment', 'select' => ['id'], 'whereClause' => [ 'post.createdById<=' => '1' ], 'withDeleted' => true - ]); + ])); $expectedSql = "SELECT comment.id AS `id` " . @@ -1072,13 +1206,14 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testMatch1() { - $sql = $this->query->createSelectQuery('Article', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Article', 'select' => ['id', 'name'], 'whereClause' => [ 'MATCH_BOOLEAN:name,description:test +hello', 'id!=' => null ] - ]); + ])); $expectedSql = "SELECT article.id AS `id`, article.name AS `name` FROM `article` " . @@ -1089,12 +1224,13 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testMatch2() { - $sql = $this->query->createSelectQuery('Article', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Article', 'select' => ['id', 'name'], 'whereClause' => [ 'MATCH_NATURAL_LANGUAGE:description:"test hello"' ] - ]); + ])); $expectedSql = "SELECT article.id AS `id`, article.name AS `name` FROM `article` " . @@ -1105,7 +1241,8 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testMatch3() { - $sql = $this->query->createSelectQuery('Article', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Article', 'select' => ['id', 'MATCH_BOOLEAN:description:test'], 'whereClause' => [ 'MATCH_BOOLEAN:description:test' @@ -1113,7 +1250,7 @@ class QueryTest extends \PHPUnit\Framework\TestCase 'orderBy' => [ [2, 'DESC'] ] - ]); + ])); $expectedSql = "SELECT article.id AS `id`, MATCH (article.description) AGAINST ('test' IN BOOLEAN MODE) AS `MATCH_BOOLEAN:description:test` FROM `article` " . @@ -1125,7 +1262,8 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testMatch4() { - $sql = $this->query->createSelectQuery('Article', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Article', 'select' => ['id', ['MATCH_BOOLEAN:description:test', 'relevance']], 'whereClause' => [ 'MATCH_BOOLEAN:description:test' @@ -1133,7 +1271,7 @@ class QueryTest extends \PHPUnit\Framework\TestCase 'orderBy' => [ [2, 'DESC'] ] - ]); + ])); $expectedSql = "SELECT article.id AS `id`, MATCH (article.description) AGAINST ('test' IN BOOLEAN MODE) AS `relevance` FROM `article` " . @@ -1145,12 +1283,13 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testMatch5() { - $sql = $this->query->createSelectQuery('Article', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Article', 'select' => ['id', 'name'], 'whereClause' => [ 'MATCH_NATURAL_LANGUAGE:description:test>' => 1 ] - ]); + ])); $expectedSql = "SELECT article.id AS `id`, article.name AS `name` FROM `article` " . @@ -1161,12 +1300,13 @@ class QueryTest extends \PHPUnit\Framework\TestCase public function testMatch6() { - $sql = $this->query->createSelectQuery('Article', [ + $sql = $this->query->compose(Select::fromRaw([ + 'from' => 'Article', 'select' => ['id', 'name'], 'whereClause' => [ 'MATCH_NATURAL_LANGUAGE:(description,test)' ] - ]); + ])); $expectedSql = "SELECT article.id AS `id`, article.name AS `name` FROM `article` " . diff --git a/tests/unit/Espo/ORM/QueryBuilderTest.php b/tests/unit/Espo/ORM/QueryBuilderTest.php new file mode 100644 index 0000000000..7f415e922b --- /dev/null +++ b/tests/unit/Espo/ORM/QueryBuilderTest.php @@ -0,0 +1,126 @@ +queryBuilder = new QueryBuilder(); + } + + public function testClone1() + { + $select = $this->queryBuilder + ->select() + ->from('Test') + ->build(); + + $clonedSelect = $this->queryBuilder + ->clone($select) + ->distinct() + ->build(); + + $this->assertNotSame($clonedSelect, $select); + $this->assertInstanceOf(Select::class, $clonedSelect); + } + + public function testInsert1() + { + $insert = $this->queryBuilder + ->insert() + ->into('Test') + ->columns(['col1']) + ->values(['col1' => '1']) + ->build(); + + $this->assertInstanceOf(Insert::class, $insert); + } + + public function testInsert2() + { + $select = $this->queryBuilder + ->select() + ->from('Test') + ->build(); + + $insert = $this->queryBuilder + ->insert() + ->into('Test') + ->columns(['col1']) + ->valuesQuery($select) + ->build(); + + $this->assertInstanceOf(Insert::class, $insert); + } + + public function testDelete1() + { + $delete = $this->queryBuilder + ->delete() + ->from('Test') + ->where(['col1' => '1']) + ->limit(1) + ->build(); + + $this->assertInstanceOf(Delete::class, $delete); + } + + public function testUpdate1() + { + $update = $this->queryBuilder + ->update() + ->from('Test') + ->set(['col1' => '2']) + ->where(['col1' => '1']) + ->limit(1) + ->build(); + + $this->assertInstanceOf(Update::class, $update); + } + + public function testUpdateNoSet() + { + $this->expectException(\RuntimeException::class); + + $update = $this->queryBuilder + ->update() + ->from('Test') + ->where(['col1' => '1']) + ->limit(1) + ->build(); + } +} diff --git a/tests/unit/Espo/ORM/QueryParams/SelectBuilderTest.php b/tests/unit/Espo/ORM/QueryParams/SelectBuilderTest.php new file mode 100644 index 0000000000..5c13531f9f --- /dev/null +++ b/tests/unit/Espo/ORM/QueryParams/SelectBuilderTest.php @@ -0,0 +1,241 @@ +builder = new SelectBuilder(); + } + + public function testFrom() + { + $params = $this->builder + ->from('Test') + ->build() + ->getRawParams(); + + $this->assertEquals('Test', $params['from']); + } + + public function testSelect1() + { + $select = $this->builder + ->from('Test') + ->select(['id', 'name']) + ->select('test') + ->build(); + + $this->assertEquals(['id', 'name', 'test'], $select->getSelect()); + } + + public function testSelect2() + { + $select = $this->builder + ->from('Test') + ->select('test') + ->select(['id', 'name']) + ->build(); + + $this->assertEquals(['id', 'name'], $select->getSelect()); + } + + public function testSelect3() + { + $select = $this->builder + ->from('Test') + ->select('test', 'hello') + ->build(); + + $this->assertEquals([['test', 'hello']], $select->getSelect()); + } + + public function testCloneNotSame() + { + $builder = new SelectBuilder(); + + $select = $builder + ->from('Test') + ->build(); + + $builder = new SelectBuilder(); + + $selectCloned = $builder + ->clone($select) + ->build(); + + $this->assertNotSame($selectCloned, $select); + } + + public function testWhereNull1() + { + $select = $this->builder + ->from('Test') + ->where(['test' => null]) + ->build(); + + $raw = $select->getRawParams(); + + $this->assertEquals(['test' => null], $raw['whereClause']); + } + + public function testWhereNull2() + { + $select = $this->builder + ->from('Test') + ->where('test', null) + ->build(); + + $raw = $select->getRawParams(); + + $this->assertEquals(['test' => null], $raw['whereClause']); + } + + public function testGroupBy() + { + $select = $this->builder + ->from('Test') + ->having(['test' => null]) + ->groupBy(['test']) + ->build(); + + $raw = $select->getRawParams(); + + $this->assertEquals(['test' => null], $raw['havingClause']); + + $this->assertEquals(['test'], $raw['groupBy']); + } + + public function testClone() + { + $builder = new SelectBuilder(); + + $select = $builder + ->from('Test') + ->where('test1', '1') + ->build(); + + $builder = new SelectBuilder(); + + $selectCloned = $builder + ->clone($select) + ->distinct() + ->where('test2', '2') + ->build(); + + $params = $select->getRawParams(); + $paramsCloned = $selectCloned->getRawParams(); + + $this->assertTrue($paramsCloned['distinct']); + $this->assertFalse($params['distinct'] ?? false); + + $this->assertEquals(['test1' =>'1'], $params['whereClause']); + $this->assertEquals(['test1' => '1', 'test2' => '2'], $paramsCloned['whereClause']); + } + + public function testCloneException() + { + $builder = new SelectBuilder(); + + $select = $builder + ->from('Test') + ->where('test1', '1') + ->build(); + + $builder = new SelectBuilder(); + + $this->expectException(\RuntimeException::class); + + $builder + ->from('Test') + ->clone($select); + } + + public function testWhereSameKeys1() + { + $builder = new SelectBuilder(); + + $select = $builder + ->from('Test') + ->where(['test' => '1']) + ->where(['test' => '2']) + ->build(); + + $raw = $select->getRawParams(); + + $expected = [ + 'test' => '1', + ['test' => '2'], + ]; + + $this->assertEquals($expected, $raw['whereClause']); + } + + public function testWhereSameKeys2() + { + $builder = new SelectBuilder(); + + $select = $builder + ->from('Test') + ->where([ + 'OR' => [ + 'test' => '1' + ], + ]) + ->where([ + 'OR' => [ + 'test' => '2' + ], + ]) + ->build(); + + $raw = $select->getRawParams(); + + $expected = [ + 'OR' => [ + 'test' => '1' + ], + [ + 'OR' => [ + 'test' => '2' + ], + ] + ]; + + $this->assertEquals($expected, $raw['whereClause']); + } +} diff --git a/tests/unit/Espo/ORM/RDBRepositoryTest.php b/tests/unit/Espo/ORM/RDBRepositoryTest.php index 9a29903969..3c01474ae8 100644 --- a/tests/unit/Espo/ORM/RDBRepositoryTest.php +++ b/tests/unit/Espo/ORM/RDBRepositoryTest.php @@ -30,25 +30,34 @@ require_once 'tests/unit/testData/DB/Entities.php'; use Espo\ORM\{ - DB\MysqlMapper, - DB\Query\Mysql as Query, - Repositories\RDB as Repository, + Mapper\MysqlMapper, + Repository\RDBRepository as Repository, + Repository\RDBRelation, + Repository\RDBRelationSelectBuilder, EntityCollection, -}; - -use Espo\Core\ORM\{ + QueryParams\Select, + QueryBuilder, + Entity, EntityManager, EntityFactory, + CollectionFactory, }; use tests\unit\testData\Entities\Test; +use Espo\Entities; + class RDBRepositoryTest extends \PHPUnit\Framework\TestCase { protected function setUp() : void { - $entityManager = $this->entityManager = $this->getMockBuilder(EntityManager::class)->disableOriginalConstructor()->getMock(); - $entityFactory = $this->getMockBuilder(EntityFactory::class)->disableOriginalConstructor()->getMock(); + $entityManager = $this->entityManager = + $this->getMockBuilder(EntityManager::class)->disableOriginalConstructor()->getMock(); + + $entityFactory = $this->entityFactory = + $this->getMockBuilder(EntityFactory::class)->disableOriginalConstructor()->getMock(); + + $this->collectionFactory = new CollectionFactory($this->entityManager); $this->mapper = $this->getMockBuilder(MysqlMapper::class)->disableOriginalConstructor()->getMock(); @@ -56,19 +65,56 @@ class RDBRepositoryTest extends \PHPUnit\Framework\TestCase ->method('getMapper') ->will($this->returnValue($this->mapper)); + $this->queryBuilder = new QueryBuilder(); + + $entityManager + ->method('getQueryBuilder') + ->will($this->returnValue($this->queryBuilder)); + + $entityManager + ->method('getQueryBuilder') + ->will($this->returnValue($this->queryBuilder)); + + $entityManager + ->method('getCollectionFactory') + ->will($this->returnValue($this->collectionFactory)); + $entity = $this->seed = $this->createEntity('Test', Test::class); - $this->collection = $this->getMockBuilder(EntityCollection::class)->disableOriginalConstructor()->getMock(); + $this->account = $this->createEntity('Account', Entities\Account::class); + $this->team = $this->createEntity('Team', Entities\Team::class); + + $this->collection = $this->createCollectionMock(); $entityFactory ->method('create') - ->will($this->returnValue($entity)); + ->will( + $this->returnCallback( + function (string $entityType) { + $className = 'Espo\\Entities\\' . ucfirst($entityType); - $this->repository = new Repository('Test', $entityManager, $entityFactory); + return $this->createEntity($entityType, $className); + } + ) + ); - $entityManager + $this->repository = $this->createRepository('Test'); + } + + protected function createCollectionMock() : EntityCollection + { + return $this->getMockBuilder(EntityCollection::class)->disableOriginalConstructor()->getMock(); + } + + protected function createRepository(string $entityType) + { + $repository = new Repository($entityType, $this->entityManager, $this->entityFactory); + + $this->entityManager ->method('getRepository') - ->will($this->returnValue($this->repository)); + ->will($this->returnValue($repository)); + + return $repository; } protected function createEntity(string $entityType, string $className) @@ -76,6 +122,9 @@ class RDBRepositoryTest extends \PHPUnit\Framework\TestCase return new $className($entityType, [], $this->entityManager); } + /** + * @deprecated + */ public function testFind() { $params = [ @@ -84,22 +133,23 @@ class RDBRepositoryTest extends \PHPUnit\Framework\TestCase ], ]; - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'whereClause' => [ 'name' => 'test', ], - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); $this->repository->find($params); } - public function testFindOne() + public function testFindOne1() { $params = [ 'whereClause' => [ @@ -107,239 +157,1043 @@ class RDBRepositoryTest extends \PHPUnit\Framework\TestCase ], ]; - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'whereClause' => [ 'name' => 'test', ], 'offset' => 0, 'limit' => 1, - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); $this->repository->findOne($params); } - public function testWhere1() + public function testFindOne2() { - $paramsExpected = [ - 'whereClause' => [ - 'name' => 'test', - ], - ]; + $select = $this->queryBuilder + ->select() + ->from('Test') + ->where(['name' => 'test']) + ->limit(0, 1) + ->build(); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($select); + + $this->repository->where(['name' => 'test'])->findOne(); + } + + public function testFindOne3() + { + $select = $this->queryBuilder + ->select() + ->distinct() + ->from('Test') + ->where(['name' => 'test']) + ->limit(0, 1) + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('select') + ->will($this->returnValue($this->collection)) + ->with($select); + + $this->repository->distinct()->findOne([ + 'whereClause' => ['name' => 'test'], + ]); + } + + /** + * @deprecated + */ + public function testCount1() + { + $select = $this->queryBuilder + ->select() + ->from('Test') + ->where(['name' => 'test']) + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('count') + ->will($this->returnValue(1)) + ->with($select); + + $this->repository->count([ + 'whereClause' => ['name' => 'test'], + ]); + } + + public function testCount2() + { + $select = $this->queryBuilder + ->select() + ->from('Test') + ->where(['name' => 'test']) + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('count') + ->will($this->returnValue(1)) + ->with($select); + + $this->repository->where(['name' => 'test'])->count(); + } + + public function testCount3() + { + $select = $this->queryBuilder + ->select() + ->from('Test') + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('count') + ->will($this->returnValue(1)) + ->with($select); + + $this->repository->count(); + } + + public function testMax1() + { + $select = $this->queryBuilder + ->select() + ->from('Test') + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('max') + ->will($this->returnValue(1)) + ->with($select, 'test'); + + $this->repository->max('test'); + } + + public function testWhere1() + { + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', + 'whereClause' => [ + 'name' => 'test', + ], + ]); + + $this->mapper + ->expects($this->once()) + ->method('select') + ->will($this->returnValue($this->collection)) + ->with($paramsExpected); $this->repository->where(['name' => 'test'])->find(); } public function testWhere2() { - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'whereClause' => [ - ['name' => 'test'], + 'name' => 'test', ], - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); $this->repository->where('name', 'test')->find(); } - public function testWhereFineOne() + public function testWhereMerge() { - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'whereClause' => [ - ['name' => 'test'], + 'name2' => 'test2', + ['name1' => 'test1'], ], - 'offset' => 0, - 'limit' => 1, - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); + + $this->repository + ->where(['name1' => 'test1']) + ->find([ + 'whereClause' => ['name2' => 'test2'], + ]); + } + + public function testWhereFineOne() + { + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', + 'whereClause' => [ + 'name' => 'test', + ], + 'offset' => 0, + 'limit' => 1, + ]); + + $this->mapper + ->expects($this->once()) + ->method('select') + ->will($this->returnValue($this->collection)) + ->with($paramsExpected); $this->repository->where('name', 'test')->findOne(); } public function testJoin1() { - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'joins' => [ 'Test', ], - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); $this->repository->join('Test')->find(); } public function testJoin2() { - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'joins' => [ 'Test1', 'Test2', ], - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); $this->repository->join(['Test1', 'Test2'])->find(); } public function testJoin3() { - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'joins' => [ ['Test1', 'test1'], ['Test2', 'test2'], ], - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); $this->repository->join([['Test1', 'test1'], ['Test2', 'test2']])->find(); } public function testJoin4() { - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'joins' => [ ['Test1', 'test1', ['k' => 'v']], ], - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); $this->repository->join('Test1', 'test1', ['k' => 'v'])->find(); } public function testLeftJoin1() { - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'leftJoins' => [ 'Test', ], - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); $this->repository->leftJoin('Test')->find(); } public function testMultipleLeftJoins() { - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'leftJoins' => [ 'Test1', ['Test2', 'test2'], ], - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); $this->repository->leftJoin('Test1')->leftJoin('Test2', 'test2')->find(); } public function testDistinct() { - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'distinct' => true, - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); $this->repository->distinct()->find(); } public function testSth() { - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'returnSthCollection' => true, - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); $this->repository->sth()->find(); } public function testOrder1() { - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'orderBy' => 'name', 'order' => 'ASC', - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); $this->repository->order('name')->find(); } - public function testSelect() + public function testSelect1() { - $paramsExpected = [ + $paramsExpected = Select::fromRaw([ + 'from' => 'Test', 'select' => ['name', 'date'], - ]; + ]); $this->mapper ->expects($this->once()) ->method('select') ->will($this->returnValue($this->collection)) - ->with($this->seed, $paramsExpected); + ->with($paramsExpected); $this->repository->select(['name', 'date'])->find(); } + + public function testSelect2() + { + $select = $this->queryBuilder + ->select() + ->from('Test') + ->select(['name']) + ->select('date') + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('select') + ->will($this->returnValue($this->collection)) + ->with($select); + + $this->repository + ->select(['name']) + ->select('date') + ->find(); + } + + public function testFindRelated1() + { + $select = Select::fromRaw([ + 'from' => 'Team', + ]); + + $this->account->id = 'accountId'; + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue(new EntityCollection())) + ->with($this->account, 'teams', $select); + + $this->createRepository('Account')->findRelated($this->account, 'teams'); + } + + public function testCountRelated1() + { + $select = Select::fromRaw([ + 'from' => 'Team', + ]); + + $this->account->id = 'accountId'; + + $this->mapper + ->expects($this->once()) + ->method('countRelated') + ->will($this->returnValue(1)) + ->with($this->account, 'teams', $select); + + $this->createRepository('Account')->countRelated($this->account, 'teams'); + } + + public function testAdditionalColumns() + { + $select = $this->queryBuilder + ->select() + ->from('Team') + ->select(['*', ['entityTeam.deleted', 'teamDeleted']]) + ->build(); + + $this->account->id = 'accountId'; + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue(new EntityCollection())) + ->with($this->account, 'teams', $select); + + $this->createRepository('Account')->findRelated($this->account, 'teams', [ + 'additionalColumns' => [ + 'deleted' => 'teamDeleted', + ], + ]); + } + + public function testAdditionalColumnsConditions() + { + $select = $this->queryBuilder + ->select() + ->from('Team') + ->where([ + 'entityTeam.teamId' => 'testId', + ]) + ->build(); + + $this->account->id = 'accountId'; + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue(new EntityCollection())) + ->with($this->account, 'teams', $select); + + $this->createRepository('Account')->findRelated($this->account, 'teams', [ + 'additionalColumnsConditions' => [ + 'teamId' => 'testId', + ], + ]); + } + + public function testClone1() + { + $select = $this->queryBuilder + ->select() + ->from('Test') + ->build(); + + $selectExpected = $this->queryBuilder + ->select() + ->from('Test') + ->select('id') + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('select') + ->will($this->returnValue(new EntityCollection())) + ->with($selectExpected); + + $this->repository + ->clone($select) + ->select('id') + ->find(); + } + + public function testGetById1() + { + $select = $this->queryBuilder + ->select() + ->from('Test') + ->where(['id' => '1']) + ->build(); + + $entity = $this->getMockBuilder(Entity::class)->getMock(); + + $this->mapper + ->expects($this->once()) + ->method('selectOne') + ->will($this->returnValue($entity)) + ->with($select); + + $this->repository->getById('1'); + } + + public function testRelationInstance() + { + $repository = $this->createRepository('Account'); + + $account = $this->entityFactory->create('Account'); + $account->id = 'accountId'; + + $relation = $repository->getRelation($account, 'teams'); + + $this->assertInstanceOf(RDBRelation::class, $relation); + } + + public function testRelationCloneInstance() + { + $repository = $this->createRepository('Account'); + + $account = $this->entityFactory->create('Account'); + $account->id = 'accountId'; + + $select = $this->queryBuilder + ->select() + ->from('Team') + ->build(); + + $relationSelectBuilder = $repository->getRelation($account, 'teams')->clone($select); + + $this->assertInstanceOf(RDBRelationSelectBuilder::class, $relationSelectBuilder); + } + + public function testRelationCloneBelongsToParentException() + { + $repository = $this->createRepository('Note'); + + $note = $this->entityFactory->create('Note'); + $note->id = 'noteId'; + + $select = $this->queryBuilder + ->select() + ->from('Post') + ->build(); + + $this->expectException(RuntimeException::class); + + $relationSelectBuilder = $repository->getRelation($note, 'parent')->clone($select); + } + + public function testRelationCount() + { + $post = $this->entityFactory->create('Post'); + $post->id = 'postId'; + + $select = $this->queryBuilder + ->select() + ->from('Comment') + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('countRelated') + ->will($this->returnValue(1)) + ->with($post, 'comments', $select); + + $this->createRepository('Post')->getRelation($post, 'comments')->count(); + } + + public function testRelationFindHasMany() + { + $repository = $this->createRepository('Post'); + + $post = $this->entityFactory->create('Post'); + $post->id = 'postId'; + + $collection = $this->collectionFactory->create(); + + $select = $this->queryBuilder + ->select() + ->from('Comment') + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue($collection)) + ->with($post, 'comments', $select); + + $relationSelectBuilder = $repository->getRelation($post, 'comments')->find(); + } + + public function testRelationFindBelongsTo() + { + $comment = $this->entityFactory->create('Comment'); + $comment->id = 'commentId'; + + $post = $this->entityFactory->create('Post'); + $post->id = 'postId'; + + $collection = $this->collectionFactory->create(); + + $select = $this->queryBuilder + ->select() + ->from('Post') + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue($post)) + ->with($comment, 'post', $select); + + $result = $this->createRepository('Comment') + ->getRelation($comment, 'post') + ->find(); + + $this->assertEquals(1, count($result)); + + $this->assertEquals($post, $result[0]); + } + + public function testRelationFindBelongsToParent() + { + $note = $this->entityFactory->create('Note'); + $note->id = 'noteId'; + + $post = $this->entityFactory->create('Post'); + $post->id = 'noteId'; + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue($post)) + ->with($note, 'parent',); + + $result = $this->createRepository('Note')->getRelation($note, 'parent')->find(); + + $this->assertEquals(1, count($result)); + + $this->assertEquals($post, $result[0]); + } + + public function testRelationFindOneBelongsToParent() + { + $note = $this->entityFactory->create('Note'); + $note->id = 'noteId'; + + $post = $this->entityFactory->create('Post'); + $post->id = 'postId'; + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue($post)) + ->with($note, 'parent'); + + $result = $this->createRepository('Note')->getRelation($note, 'parent')->findOne(); + + $this->assertEquals($post, $result); + } + + public function testRelationIsRelated1() + { + $note = $this->entityFactory->create('Note'); + $note->set('id', 'noteId'); + + $post = $this->entityFactory->create('Post'); + $post->set('id', 'postId'); + + $note->set('parentId', $post->id); + $note->set('parentType', 'Post'); + + $result = $this->createRepository('Note')->getRelation($note, 'parent')->isRelated($post); + + $this->assertTrue($result); + + $note->set('parentId', 'anotherId'); + $note->set('parentType', 'Post'); + + $result = $this->createRepository('Note')->getRelation($note, 'parent')->isRelated($post); + + $this->assertFalse($result); + } + + public function testRelationIsRelated2() + { + $post = $this->entityFactory->create('Post'); + $post->set('id', 'postId'); + + $note = $this->entityFactory->create('Note'); + $note->set('id', 'noteId'); + + $collection = $this->collectionFactory->create('Note', [$note]); + + $select = $this->queryBuilder + ->select() + ->from('Note') + ->select(['id']) + ->where(['id' => $note->id]) + ->limit(0, 1) + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue($collection)) + ->with($post, 'notes', $select); + + $result = $this->createRepository('Post')->getRelation($post, 'notes')->isRelated($note); + + $this->assertTrue($result); + } + + public function testRelationIsRelated3() + { + $post = $this->entityFactory->create('Post'); + $post->set('id', 'postId'); + + $note = $this->entityFactory->create('Note'); + $note->set('id', 'noteId'); + + $collection = $this->collectionFactory->create('Note', []); + + $select = $this->queryBuilder + ->select() + ->from('Note') + ->select(['id']) + ->where(['id' => $note->id]) + ->limit(0, 1) + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue($collection)) + ->with($post, 'notes', $select); + + $result = $this->createRepository('Post')->getRelation($post, 'notes')->isRelated($note); + + $this->assertFalse($result); + } + + public function testRelate1() + { + $post = $this->entityFactory->create('Post'); + $post->set('id', 'postId'); + + $note = $this->entityFactory->create('Note'); + $note->set('id', 'noteId'); + + $this->mapper + ->expects($this->once()) + ->method('relateById') + ->with($post, 'notes', $note->id); + + $this->createRepository('Post')->getRelation($post, 'notes')->relate($note); + } + + public function testUnrelate1() + { + $post = $this->entityFactory->create('Post'); + $post->set('id', 'postId'); + + $note = $this->entityFactory->create('Note'); + $note->set('id', 'noteId'); + + $this->mapper + ->expects($this->once()) + ->method('unrelateById') + ->with($post, 'notes', $note->id); + + $this->createRepository('Post')->getRelation($post, 'notes')->unrelate($note); + } + + public function testMassRelate() + { + $post = $this->entityFactory->create('Post'); + $post->set('id', 'postId'); + + $select = $this->queryBuilder + ->select() + ->from('Note') + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('massRelate') + ->with($post, 'notes', $select); + + $this->createRepository('Post')->getRelation($post, 'notes')->massRelate($select); + } + + public function testGetColumn() + { + $account = $this->entityFactory->create('Account'); + $account->set('id', 'accountId'); + + $team = $this->entityFactory->create('Team'); + $team->set('id', 'teamId'); + + $this->mapper + ->expects($this->once()) + ->method('getRelationColumn') + ->with($account, 'teams', $team->id, 'test'); + + $this->createRepository('Post')->getRelation($account, 'teams')->getColumn($team, 'test'); + } + + public function testUpdateColumns() + { + $account = $this->entityFactory->create('Account'); + $account->set('id', 'accountId'); + + $team = $this->entityFactory->create('Team'); + $team->set('id', 'teamId'); + + $columns = [ + 'column' => 'test', + ]; + + $this->mapper + ->expects($this->once()) + ->method('updateRelationColumns') + ->with($account, 'teams', $team->id, $columns); + + $this->createRepository('Post')->getRelation($account, 'teams')->updateColumns($team, $columns); + } + + public function testRelationSelectBuilderFind() + { + $repository = $this->createRepository('Post'); + + $post = $this->entityFactory->create('Post'); + $post->id = 'postId'; + + $collection = $this->createCollectionMock(); + + $select = $this->queryBuilder + ->select() + ->from('Comment') + ->select(['id']) + ->distinct() + ->where(['name' => 'test']) + ->join('Test', 'test', ['id:' => 'id']) + ->order('id', 'DESC') + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue($collection)) + ->with($post, 'comments', $select); + + $repository->getRelation($post, 'comments') + ->select(['id']) + ->distinct() + ->where(['name' => 'test']) + ->join('Test', 'test', ['id:' => 'id']) + ->order('id', 'DESC') + ->find(); + } + + public function testRelationSelectBuilderFindOne() + { + $repository = $this->createRepository('Post'); + + $post = $this->entityFactory->create('Post'); + $post->id = 'postId'; + + $collection = $this->collectionFactory->create(); + + $comment = $this->entityFactory->create('Comment'); + $comment->set('id', 'commentId'); + + $collection[] = $comment; + + $select = $this->queryBuilder + ->select() + ->from('Comment') + ->select(['id']) + ->distinct() + ->where(['name' => 'test']) + ->join('Test', 'test', ['id:' => 'id']) + ->order('id', 'DESC') + ->limit(0, 1) + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue($collection)) + ->with($post, 'comments', $select); + + $result = $repository->getRelation($post, 'comments') + ->select(['id']) + ->distinct() + ->where(['name' => 'test']) + ->join('Test', 'test', ['id:' => 'id']) + ->order('id', 'DESC') + ->findOne(); + + $this->assertEquals($comment, $result); + } + + public function testRelationSelectBuilderColumns1() + { + $account = $this->entityFactory->create('Account'); + $account->set('id', 'accountId'); + + $collection = $this->collectionFactory->create(); + + $select = $this->queryBuilder + ->select() + ->from('Team') + ->select(['*', ['entityTeam.deleted', 'test']]) + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue($collection)) + ->with($account, 'teams', $select); + + $this->createRepository('Account')->getRelation($account, 'teams') + ->columns(['deleted' => 'test']) + ->find(); + } + + public function testRelationSelectBuilderColumns2() + { + $account = $this->entityFactory->create('Account'); + $account->set('id', 'accountId'); + + $collection = $this->collectionFactory->create(); + + $select = $this->queryBuilder + ->select() + ->from('Team') + ->select(['id', ['entityTeam.deleted', 'test']]) + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue($collection)) + ->with($account, 'teams', $select); + + $this->createRepository('Account')->getRelation($account, 'teams') + ->select(['id']) + ->columns(['deleted' => 'test']) + ->find(); + } + + public function testRelationSelectBuilderColumnsWhere1() + { + $account = $this->entityFactory->create('Account'); + $account->set('id', 'accountId'); + + $collection = $this->collectionFactory->create(); + + $select = $this->queryBuilder + ->select() + ->from('Team') + ->where(['entityTeam.deleted' => false]) + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue($collection)) + ->with($account, 'teams', $select); + + + $this->createRepository('Account')->getRelation($account, 'teams') + ->columnsWhere(['deleted' => false]) + ->find(); + } + + public function testRelationSelectBuilderColumnsWhere2() + { + $account = $this->entityFactory->create('Account'); + $account->set('id', 'accountId'); + + $collection = $this->collectionFactory->create(); + + $select = $this->queryBuilder + ->select() + ->from('Team') + ->where([ + 'OR' => [ + ['entityTeam.deleted' => false], + ['entityTeam.deleted' => null], + ] + ]) + ->build(); + + $this->mapper + ->expects($this->once()) + ->method('selectRelated') + ->will($this->returnValue($collection)) + ->with($account, 'teams', $select); + + + $this->createRepository('Account')->getRelation($account, 'teams') + ->columnsWhere([ + 'OR' => [ + ['deleted' => false], + ['deleted' => null], + ] + ]) + ->find(); + } } diff --git a/tests/unit/testData/DB/Entities.php b/tests/unit/testData/DB/Entities.php index fc91c9a16a..224293a057 100644 --- a/tests/unit/testData/DB/Entities.php +++ b/tests/unit/testData/DB/Entities.php @@ -12,23 +12,23 @@ class TEntity extends BaseEntity class Account extends TEntity { - public $fields = array( - 'id' => array( + public $fields = [ + 'id' => [ 'type' => Entity::ID, - ), - 'name' => array( + ], + 'name' => [ 'type' => Entity::VARCHAR, 'len' => 255, - ), - 'deleted' => array( + ], + 'deleted' => [ 'type' => Entity::BOOL, 'default' => 0, - ), + ], 'stub' => [ 'type' => 'varchar', 'notStorable' => true, ], - ); + ]; public $relations = [ 'teams' => [ 'type' => Entity::MANY_MANY, @@ -38,26 +38,26 @@ class Account extends TEntity 'entityId', 'teamId', ], - 'conditions' => ['entityType' => 'Account'] + 'conditions' => ['entityType' => 'Account'], ], ]; } class Team extends TEntity { - public $fields = array( - 'id' => array( + public $fields = [ + 'id' => [ 'type' => Entity::ID, - ), - 'name' => array( + ], + 'name' => [ 'type' => Entity::VARCHAR, 'len' => 255, - ), - 'deleted' => array( + ], + 'deleted' => [ 'type' => Entity::BOOL, 'default' => 0, - ), - ); + ], + ]; public $relations = []; } @@ -90,133 +90,134 @@ class EntityTeam extends TEntity class Contact extends TEntity { - public $fields = array( - 'id' => array( + public $fields = [ + 'id' => [ 'type' => Entity::ID, - ), - 'name' => array( + ], + 'name' => [ 'type' => Entity::VARCHAR, 'notStorable' => true, 'select' => "TRIM(CONCAT(contact.first_name, ' ', contact.last_name))", - 'where' => array( - 'LIKE' => "(contact.first_name LIKE {value} OR contact.last_name LIKE {value} OR CONCAT(contact.first_name, ' ', contact.last_name) LIKE {value})", - ), + 'where' => [ + 'LIKE' => "(contact.first_name LIKE {value} OR contact.last_name LIKE {value} OR " + ."CONCAT(contact.first_name, ' ', contact.last_name) LIKE {value})", + ], 'orderBy' => "contact.first_name {direction}, contact.last_name {direction}", - ), - 'firstName' => array( + ], + 'firstName' => [ 'type' => Entity::VARCHAR, - ), - 'lastName' => array( + ], + 'lastName' => [ 'type' => Entity::VARCHAR, - ), - 'deleted' => array( + ], + 'deleted' => [ 'type' => Entity::BOOL, 'default' => 0, - ), - ); - public $relations = array(); + ], + ]; + public $relations = []; } class Post extends TEntity { - public $fields = array( - 'id' => array( + public $fields = [ + 'id' => [ 'type' => Entity::ID, - ), - 'name' => array( + ], + 'name' => [ 'type' => Entity::VARCHAR, 'len' => 255, - ), - 'privateField' => array( + ], + 'privateField' => [ 'notStorable' => true, - ), - 'createdByName' => array( + ], + 'createdByName' => [ 'type' => Entity::FOREIGN, 'relation' => 'createdBy', - 'foreign' => array('salutationName', 'firstName', ' ', 'lastName'), - ), - 'createdById' => array( + 'foreign' => ['salutationName', 'firstName', ' ', 'lastName'], + ], + 'createdById' => [ 'type' => Entity::FOREIGN_ID, - ), - 'deleted' => array( + ], + 'deleted' => [ 'type' => Entity::BOOL, 'default' => 0, - ), - ); - public $relations = array( - 'tags' => array( + ], + ]; + public $relations = [ + 'tags' => [ 'type' => Entity::MANY_MANY, 'entity' => 'Tag', 'relationName' => 'PostTag', 'key' => 'id', 'foreignKey' => 'id', - 'midKeys' => array( + 'midKeys' => [ 'postId', 'tagId', - ), + ], 'additionalColumns' => [ 'role' => [ 'type' => Entity::VARCHAR, ], ], - ), - 'comments' => array( + ], + 'comments' => [ 'type' => Entity::HAS_MANY, 'entity' => 'Comment', 'foreignKey' => 'postId', - ), - 'createdBy' => array( + ], + 'createdBy' => [ 'type' => Entity::BELONGS_TO, 'entity' => 'User', 'key' => 'createdById', - ), - 'notes' => array( + ], + 'notes' => [ 'type' => Entity::HAS_CHILDREN, 'entity' => 'Note', 'foreignKey' => 'parentId', 'foreignType' => 'parentType', - ), + ], 'postData' => [ 'type' => Entity::HAS_ONE, 'entity' => 'PostData', 'foreign' => 'post', 'noJoin' => true, ], - ); + ]; } class Comment extends TEntity { - public $fields = array( - 'id' => array( - 'type' => Entity::ID, - ), - 'postId' => array( - 'type' => Entity::FOREIGN_ID, - ), - 'postName' => array( - 'type' => Entity::FOREIGN, - 'relation' => 'post', - 'foreign' => 'name', - ), - 'name' => array( - 'type' => Entity::VARCHAR, - 'len' => 255, - ), - 'deleted' => array( - 'type' => Entity::BOOL, - 'default' => 0, - ), - ); + public $fields = [ + 'id' => [ + 'type' => Entity::ID, + ], + 'postId' => [ + 'type' => Entity::FOREIGN_ID, + ], + 'postName' => [ + 'type' => Entity::FOREIGN, + 'relation' => 'post', + 'foreign' => 'name', + ], + 'name' => [ + 'type' => Entity::VARCHAR, + 'len' => 255, + ], + 'deleted' => [ + 'type' => Entity::BOOL, + 'default' => 0, + ], + ]; - public $relations = array( - 'post' => array( - 'type' => Entity::BELONGS_TO, - 'entity' => 'Post', - 'key' => 'postId', - 'foreignKey' => 'id', - ), - ); + public $relations = [ + 'post' => [ + 'type' => Entity::BELONGS_TO, + 'entity' => 'Post', + 'key' => 'postId', + 'foreignKey' => 'id', + ], + ]; } class PostData extends TEntity @@ -247,19 +248,19 @@ class PostData extends TEntity class Tag extends TEntity { - public $fields = array( - 'id' => array( + public $fields = [ + 'id' => [ 'type' => Entity::ID, - ), - 'name' => array( + ], + 'name' => [ 'type' => Entity::VARCHAR, 'len' => 50, - ), - 'deleted' => array( + ], + 'deleted' => [ 'type' => Entity::BOOL, 'default' => 0, - ), - ); + ], + ]; } class PostTag extends TEntity @@ -290,29 +291,29 @@ class PostTag extends TEntity class Note extends TEntity { - public $fields = array( - 'id' => array( + public $fields = [ + 'id' => [ 'type' => Entity::ID, - ), - 'name' => array( + ], + 'name' => [ 'type' => Entity::VARCHAR, 'len' => 50, - ), - 'parentId' => array( + ], + 'parentId' => [ 'type' => Entity::FOREIGN_ID, - ), - 'parentType' => array( + ], + 'parentType' => [ 'type' => Entity::FOREIGN_TYPE, - ), - 'parentName' => array( + ], + 'parentName' => [ 'type' => Entity::VARCHAR, 'notStorable' => true, - ), - 'deleted' => array( + ], + 'deleted' => [ 'type' => Entity::BOOL, 'default' => 0, - ), - ); + ], + ]; public $relations = [ 'parent' => [ @@ -326,49 +327,82 @@ class Note extends TEntity class Article extends TEntity { - public $fields = array( - 'id' => array( + public $fields = [ + 'id' => [ 'type' => Entity::ID - ), - 'name' => array( + ], + 'name' => [ 'type' => Entity::VARCHAR, 'len' => 50, - ), - 'description' => array( + ], + 'description' => [ 'type' => Entity::TEXT - ), - 'deleted' => array( + ], + 'deleted' => [ 'type' => Entity::BOOL, 'default' => 0 - ) - ); + ] + ]; } - class Job extends TEntity { - public $fields = array( - 'id' => array( + public $fields = [ + 'id' => [ 'type' => Entity::ID - ), - 'string' => array( + ], + 'string' => [ 'type' => Entity::VARCHAR, 'len' => 50 - ), - 'array' => array( + ], + 'array' => [ 'type' => Entity::JSON_ARRAY - ), - 'arrayUnordered' => array( + ], + 'arrayUnordered' => [ 'type' => Entity::JSON_ARRAY, 'isUnordered' => true - ), - 'object' => array( + ], + 'object' => [ 'type' => Entity::JSON_OBJECT - ), - 'deleted' => array( + ], + 'deleted' => [ 'type' => Entity::BOOL, 'default' => 0 - ) - ); + ], + ]; } +class Test extends TEntity +{ + public $fields = [ + 'id' => [ + 'type' => Entity::ID, + ], + 'name' => [ + 'type' => Entity::VARCHAR, + 'len' => 255, + ], + 'date' => [ + 'type' => Entity::DATE + ], + 'dateTime' => [ + 'type' => Entity::DATETIME + ], + 'int' => [ + 'type' => Entity::INT + ], + 'float' => [ + 'type' => Entity::FLOAT + ], + 'list' => [ + 'type' => Entity::JSON_ARRAY + ], + 'object' => [ + 'type' => Entity::JSON_OBJECT + ], + 'deleted' => [ + 'type' => Entity::BOOL, + 'default' => 0, + ] + ]; +}