. * * The interactive user interfaces in modified source and object code versions * of this program must display Appropriate Legal Notices, as required under * Section 5 of the GNU Affero General Public License version 3. * * In accordance with Section 7(b) of the GNU Affero General Public License version 3, * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ namespace Espo\ORM\Repository; use Espo\Core\ORM\Repository\Option\SaveContext; use Espo\ORM\Defs\RelationDefs; use Espo\ORM\EntityCollection; use Espo\ORM\EntityManager; use Espo\ORM\EntityFactory; use Espo\ORM\Name\Attribute; use Espo\ORM\Relation\Relations; use Espo\ORM\Relation\RelationsMap; use Espo\ORM\Repository\Deprecation\RDBRepositoryDeprecationTrait; use Espo\ORM\Repository\Option\SaveOption; use Espo\ORM\SthCollection; use Espo\ORM\BaseEntity; use Espo\ORM\Entity; use Espo\ORM\Mapper\RDBMapper; use Espo\ORM\Query\Select; use Espo\ORM\Query\Part\WhereItem; use Espo\ORM\Query\Part\Selection; use Espo\ORM\Query\Part\Join; use Espo\ORM\Query\Part\Expression; use Espo\ORM\Query\Part\Order; use Espo\ORM\Mapper\BaseMapper; use Espo\ORM\Type\RelationType; use RuntimeException; /** * A relation database repository. * * @template TEntity of Entity * @implements Repository */ class RDBRepository implements Repository { /** @phpstan-use RDBRepositoryDeprecationTrait */ use RDBRepositoryDeprecationTrait; protected HookMediator $hookMediator; protected RDBTransactionManager $transactionManager; public function __construct( protected string $entityType, protected EntityManager $entityManager, protected EntityFactory $entityFactory, ?HookMediator $hookMediator = null, private ?RelationsMap $relationsMap = null, ) { $this->hookMediator = $hookMediator ?? (new EmptyHookMediator()); $this->transactionManager = new RDBTransactionManager($entityManager->getTransactionManager()); } public function getEntityType(): string { return $this->entityType; } /** * Get a new entity. * * @return TEntity */ public function getNew(): Entity { $entity = $this->entityFactory->create($this->entityType); if ($entity instanceof BaseEntity) { $entity->populateDefaults(); } /** @var TEntity */ return $entity; } /** * Fetch an entity by ID. * * @return ?TEntity */ public function getById(string $id): ?Entity { $selectQuery = $this->entityManager ->getQueryBuilder() ->select() ->from($this->entityType) ->where([Attribute::ID => $id]) ->build(); /** @var ?TEntity $entity */ $entity = $this->getMapper()->selectOne($selectQuery); return $entity; } protected function processCheckEntity(Entity $entity): void { if ($entity->getEntityType() !== $this->entityType) { throw new RuntimeException("An entity type doesn't match the repository."); } } /** * @param TEntity $entity * @param array $options */ public function save(Entity $entity, array $options = []): void { if (!isset($options[SaveContext::NAME])) { $options[SaveContext::NAME] = new SaveContext(); } $this->processCheckEntity($entity); if ($entity instanceof BaseEntity) { $entity->setAsBeingSaved(); } if (empty($options['skipBeforeSave']) && empty($options[SaveOption::SKIP_ALL])) { $this->beforeSave($entity, $options); } $isSaved = false; if ($entity instanceof BaseEntity) { $isSaved = $entity->isSaved(); } if ($entity->isNew() && !$isSaved) { $this->getMapper()->insert($entity); } else { $this->getMapper()->update($entity); } $this->saveSetRelations($entity); if ($entity instanceof BaseEntity) { $entity->setAsSaved(); } if ( empty($options['skipAfterSave']) && empty($options[SaveOption::SKIP_ALL]) ) { $this->afterSave($entity, $options); } if ($entity->isNew()) { if (empty($options[SaveOption::KEEP_NEW])) { $entity->setAsNotNew(); $entity->updateFetchedValues(); } } else if (empty($options[SaveOption::KEEP_DIRTY])) { $entity->updateFetchedValues(); } if ($entity instanceof BaseEntity) { $entity->setAsNotBeingSaved(); } if (empty($options[SaveOption::KEEP_RELATIONS])) { $this->relationsMap?->get($entity)?->resetAll(); } } /** * Restore a record flagged as deleted. */ public function restoreDeleted(string $id): void { $mapper = $this->getMapper(); if (!$mapper instanceof BaseMapper) { throw new RuntimeException("Not supported 'restoreDeleted'."); } $mapper->restoreDeleted($this->entityType, $id); } /** * Get an access point for a specific relation of a record. * * @param TEntity $entity * @return RDBRelation */ 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 = []): void { $this->processCheckEntity($entity); $this->beforeRemove($entity, $options); $this->getMapper()->delete($entity); $this->afterRemove($entity, $options); } /** * Find records. * * @return EntityCollection|SthCollection */ public function find(): EntityCollection|SthCollection { return $this->createBuilder()->find(); } /** * Find one record. */ public function findOne(): ?Entity { $collection = $this->limit(0, 1)->find(); foreach ($collection as $entity) { return $entity; } return null; } /** * Find records by an SQL query. * * @return SthCollection */ public function findBySql(string $sql): SthCollection { $mapper = $this->getMapper(); if (!$mapper instanceof BaseMapper) { throw new RuntimeException("Not supported 'findBySql'."); } /** @var SthCollection */ return $mapper->selectBySql($this->entityType, $sql); } /** * Get a number of records. */ public function count(): int { return $this->createBuilder()->count(); } /** * Get a max value. * * @return int|float */ public function max(string $attribute) { return $this->createBuilder()->max($attribute); } /** * Get a min value. * * @return int|float */ public function min(string $attribute) { return $this->createBuilder()->min($attribute); } /** * Get a sum value. * * @return int|float */ public function sum(string $attribute) { return $this->createBuilder()->sum($attribute); } /** * Clone an existing query for a further modification and usage by 'find' or 'count' methods. * * @return RDBSelectBuilder */ public function clone(Select $query): RDBSelectBuilder { if ($this->entityType !== $query->getFrom()) { throw new RuntimeException("Can't clone a query of a different entity type."); } /** @var RDBSelectBuilder $builder */ $builder = new RDBSelectBuilder($this->entityManager, $this->entityType, $query); return $builder; } /** * Add JOIN. * * @param Join|string $target * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. * @return RDBSelectBuilder */ public function join($target, ?string $alias = null, $conditions = null): RDBSelectBuilder { return $this->createBuilder()->join($target, $alias, $conditions); } /** * Add LEFT JOIN. * * @param Join|string $target * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. * @return RDBSelectBuilder */ public function leftJoin($target, ?string $alias = null, $conditions = null): RDBSelectBuilder { return $this->createBuilder()->leftJoin($target, $alias, $conditions); } /** * Set DISTINCT parameter. * * @return RDBSelectBuilder */ public function distinct(): RDBSelectBuilder { return $this->createBuilder()->distinct(); } /** * Lock selected rows. To be used within a transaction. * * @return RDBSelectBuilder */ public function forUpdate(): RDBSelectBuilder { return $this->createBuilder()->forUpdate(); } /** * Set to return STH collection. Recommended fetching large number of records. * * @return RDBSelectBuilder */ public function sth(): RDBSelectBuilder { return $this->createBuilder()->sth(); } /** * Add a WHERE clause. * * Usage options: * * `where(WhereItem $clause)` * * `where(array $clause)` * * `where(string $key, string $value)` * * @param WhereItem|array|string $clause A key or where clause. * @param mixed[]|scalar|null $value A value. Should be omitted if the first argument is not string. * @return RDBSelectBuilder */ public function where($clause = [], $value = null): RDBSelectBuilder { return $this->createBuilder()->where($clause, $value); } /** * Add a HAVING clause. * * Usage options: * * `having(WhereItem $clause)` * * `having(array $clause)` * * `having(string $key, string $value)` * * @param WhereItem|array|string $clause A key or where clause. * @param mixed[]|scalar|null $value A value. Should be omitted if the first argument is not string. * @return RDBSelectBuilder */ public function having($clause = [], $value = null): RDBSelectBuilder { return $this->createBuilder()->having($clause, $value); } /** * Apply ORDER. Passing an array will override previously set items. * Passing non-array will append an item, * * Usage options: * * `order(Order $expression) * * `order([$expr1, $expr2, ...]) * * `order(string $expression, string $direction) * * @param Order|Order[]|Expression|string|array|string[] $orderBy * An attribute to order by or an array or order items. * Passing an array will reset a previously set order. * @param (Order::ASC|Order::DESC)|bool|null $direction A direction. * @return RDBSelectBuilder */ public function order($orderBy = Attribute::ID, $direction = null): RDBSelectBuilder { return $this->createBuilder()->order($orderBy, $direction); } /** * Apply OFFSET and LIMIT. * * @return RDBSelectBuilder */ public function limit(?int $offset = null, ?int $limit = null): RDBSelectBuilder { return $this->createBuilder()->limit($offset, $limit); } /** * 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 SelectExpression|Expression|string will append the item. * * Usage options: * * `select(SelectExpression $expression)` * * `select([$expr1, $expr2, ...])` * * `select(string $expression, string $alias)` * * @param Selection|Selection[]|Expression|Expression[]|string[]|string|array $select * An array of expressions or one expression. * @param string|null $alias An alias. Actual if the first parameter is not an array. * @return RDBSelectBuilder */ public function select($select = [], ?string $alias = null): RDBSelectBuilder { return $this->createBuilder()->select($select, $alias); } /** * Specify GROUP BY. * Passing an array will reset previously set items. * Passing a string|Expression will append an item. * * Usage options: * * `groupBy(Expression|string $expression)` * * `groupBy([$expr1, $expr2, ...])` * * @param Expression|Expression[]|string|string[] $groupBy * @return RDBSelectBuilder */ public function group($groupBy): RDBSelectBuilder { return $this->createBuilder()->group($groupBy); } /** * Create a select builder. * * @return RDBSelectBuilder * * @since 9.2.5 */ public function createBuilder(): RDBSelectBuilder { /** @var RDBSelectBuilder $builder */ $builder = new RDBSelectBuilder($this->entityManager, $this->entityType); return $builder; } /** * Create a select builder. * * @return RDBSelectBuilder * * @deprecated Since v9.2.5. Use `createBuilder` instead. * @todo Remove in v10.0. */ protected function createSelectBuilder(): RDBSelectBuilder { return $this->createBuilder(); } /** * Use hooks instead. * * @param array $options * @return void */ protected function beforeSave(Entity $entity, array $options = []) { $this->hookMediator->beforeSave($entity, $options); } /** * @deprecated Use hooks instead. * * @param array $options * @return void */ protected function afterSave(Entity $entity, array $options = []) { $this->hookMediator->afterSave($entity, $options); } /** * @deprecated Use hooks instead. * * @param array $options * @return void */ protected function beforeRemove(Entity $entity, array $options = []) { $this->hookMediator->beforeRemove($entity, $options); } /** * @deprecated Use hooks instead. * * @param array $options * @return void */ protected function afterRemove(Entity $entity, array $options = []) { $this->hookMediator->afterRemove($entity, $options); } protected function getMapper(): RDBMapper { $mapper = $this->entityManager->getMapper(); if (!$mapper instanceof RDBMapper) { throw new RuntimeException("Mapper is not RDB."); } return $mapper; } /** * @deprecated As of v6.0. Use hooks instead. * @phpstan-ignore-next-line */ protected function beforeRelate(Entity $entity, $relationName, $foreign, $data = null, array $options = []) {} /** * @deprecated As of v6.0. Use hooks instead. * @phpstan-ignore-next-line */ protected function afterRelate(Entity $entity, $relationName, $foreign, $data = null, array $options = []) {} /** * @deprecated As of v6.0. Use hooks instead. * @phpstan-ignore-next-line */ protected function beforeUnrelate(Entity $entity, $relationName, $foreign, array $options = []) {} /** * @deprecated As of v6.0. Use hooks instead. * @phpstan-ignore-next-line */ protected function afterUnrelate(Entity $entity, $relationName, $foreign, array $options = []) {} /** * @deprecated As of v6.0. Use hooks instead. * @phpstan-ignore-next-line */ protected function beforeMassRelate(Entity $entity, $relationName, array $params = [], array $options = []) {} /** * @deprecated As of v6.0. Use hooks instead. * @phpstan-ignore-next-line */ protected function afterMassRelate(Entity $entity, $relationName, array $params = [], array $options = []) {} /** * @param TEntity $entity */ private function saveSetRelations(Entity $entity): void { if (!$this->relationsMap) { return; } $relations = $this->relationsMap->get($entity); if (!$relations) { return; } foreach ($entity->getRelationList() as $relation) { if (!$relations->isSet($relation)) { continue; } $this->saveSetRelation($entity, $relations, $relation); } } /** * @param TEntity $entity */ private function saveSetRelation(Entity $entity, Relations $relations, string $name): void { $related = $relations->getSet($name); $type = $entity->getRelationType($name); if ($type === RelationType::HAS_ONE) { $this->saveSetRelationHasOne($entity, $name, $related); } else if ($type === RelationType::BELONGS_TO) { $this->saveSetRelationBelongsTo($entity, $name, $related); } else if ($type === RelationType::BELONGS_TO_PARENT) { $this->saveSetRelationBelongsToParent($entity, $name, $related); } } /** * @param TEntity $entity */ private function saveSetRelationHasOne(Entity $entity, string $name, ?Entity $related): void { $idAttribute = $name . 'Id'; $defs = $this->getRelationDefs($name); $foreignKey = $defs->getForeignKey(); $foreignEntityType = $defs->getForeignEntityType(); $foreign = $defs->tryGetForeignRelationName(); $previous = $this->entityManager ->getRDBRepository($foreignEntityType) ->where([$foreignKey => $entity->getId()]) ->findOne(); if (!$entity->isNew()) { $entity->setFetched($idAttribute, $previous ? $previous->getId() : null); } if ($previous) { if (!$related) { $this->getRelation($entity, $name)->unrelate($previous); return; } if ($previous->getId() === $related->getId()) { return; } } if (!$related) { return; } $this->getRelation($entity, $name)->relate($related); $related->set($foreignKey, $entity->getId()); $related->setFetched($foreignKey, $entity->getId()); $related->clear($foreign . 'Name'); } /** * @param TEntity $entity */ private function saveSetRelationBelongsTo(Entity $entity, string $name, ?Entity $related): void { $setId = $entity->get($name . 'Id'); if (!$related) { if ($setId) { $this->getRelation($entity, $name)->unrelateById($setId); } return; } $defs = $this->getRelationDefs($name); $foreignType = $defs->tryGetForeignRelationName() ? $this->entityManager ->getDefs() ->getEntity($defs->getForeignEntityType()) ->getRelation($defs->getForeignRelationName()) ->getType() : null; if ($setId === $related->getId() && $foreignType !== RelationType::HAS_ONE) { return; } $this->getRelation($entity, $name)->relate($related); } private function getRelationDefs(string $relation): RelationDefs { return $this->entityManager ->getDefs() ->getEntity($this->entityType) ->getRelation($relation); } /** * @param TEntity $entity */ private function saveSetRelationBelongsToParent(Entity $entity, string $name, ?Entity $related): void { $setId = $entity->get($name . 'Id'); $setType = $entity->get($name . 'Type'); if (!$related) { if (!$setType && !$setId) { return; } $entity->setMultiple([ $name . 'Id' => null, $name . 'Type' => null, $name . 'Name' => null, ]); if (!$setType || !$setId) { return; } $previous = $this->entityManager->getEntityById($setType, $setId); if (!$previous) { return; } $this->getRelation($entity, $name)->unrelate($previous); return; } if ($setType === $related->getEntityType() && $setId === $related->getId()) { return; } $this->getRelation($entity, $name)->relate($related); } public function deleteFromDb(string $id, bool $onlyDeleted = false): void { $mapper = $this->getMapper(); if (!$mapper instanceof BaseMapper) { throw new RuntimeException("Not supported 'deleteFromDb'."); } $mapper->deleteFromDb($this->entityType, $id, $onlyDeleted); } }