|null $entityClassOverride Only meant for generic, runtime-typed * repositories (e.g. EntityManager::getRepository()). * @throws \ReflectionException * @internal * @since 35.0.0 */ public function __construct( protected readonly IDBConnection $connection, protected readonly EntityManager $entityManager, private readonly ?string $entityClassOverride = null, ) { } /** * @return class-string */ private function getEntityClass(): string { return $this->entityClassOverride ?? static::entityClass; } private function buildDebugMessage(string $msg, IQueryBuilder $sql): string { return $msg . ': query "' . $sql->getSQL() . '"; '; } /** * Builds an entity from a flat row of its own scalar columns. OneToOne relations are * always left null here; resolving them is mapJoinedRowToEntity()'s job. * * @template S of object * @param class-string $entityClass * @param array $row * @return S */ private function hydrateRow(string $entityClass, mixed $row): object { $entityInfo = $this->entityManager->getEntityInfo($entityClass); /** @psalm-suppress MixedMethodCall Entities are a contract of this ORM: every mapped entity class has a public no-argument constructor. */ $entity = new $entityClass(); /** @psalm-suppress MixedAssignment $value is a raw, untyped DB driver value. */ foreach ($row as $column => $value) { $property = $entityInfo->mappingColumnToProperty[$column]; $type = $entityInfo->mappingColumnToTypes[$column]; if ($type === ColumnType::Blob) { // (B)LOB is treated as string when we read from the DB if (is_resource($value)) { $value = stream_get_contents($value); } $type = ColumnType::String; } if ($this->isGeneratedIdColumn($entityInfo, $column)) { $entity->$property = (string)$value; continue; } if ($value === null) { $entity->$property = null; continue; } /** @psalm-suppress MixedAssignment $value is a raw DB driver value; each branch below settype()s or reconstructs it. */ $value = match ($type) { ColumnType::Bigint, ColumnType::Smallint, ColumnType::Integer => (int)$value, ColumnType::Float => (float)$value, ColumnType::Boolean => (bool)$value, ColumnType::Binary, ColumnType::Decimal, ColumnType::Text, ColumnType::String => (string)$value, ColumnType::Time, ColumnType::Date, ColumnType::Datetime, ColumnType::DatetimeTz => $value instanceof \DateTime ? $value : new \DateTime((string)$value), ColumnType::TimeImmutable, ColumnType::DateImmutable, ColumnType::DatetimeImmutable, ColumnType::DatetimeTzImmutable => $value instanceof \DateTimeImmutable ? $value : new \DateTimeImmutable((string)$value), ColumnType::Json => is_array($value) ? $value : json_decode((string)$value, true), ColumnType::Blob => $value, }; $entity->$property = $value; } foreach ($entityInfo->propertiesAttributes as $propertyAttributes) { if ($propertyAttributes->isRelation()) { $entity->{$propertyAttributes->property->getName()} = null; } } return $entity; } private function isGeneratedIdColumn(EntityInfo $entityInfo, string $column): bool { foreach ($entityInfo->propertiesAttributes as $propertyAttributes) { if ($propertyAttributes->id !== null && $propertyAttributes->column?->name === $column) { return $propertyAttributes->id->generatorClass !== null; } } return false; } /** * Builds a select query resolving OneToOne and ManyToOne relations via a LEFT JOIN. * Columns are aliased `e_` (main entity) and `r_` (each relation) * to stay unique even when tables share column names. * * @return array{0: IQueryBuilder, 1: array} */ private function buildJoinedSelectQuery(EntityInfo $entityInfo): array { $qb = $this->connection->getQueryBuilder(); $qb->from($entityInfo->tableName, 'e'); foreach (array_keys($entityInfo->mappingColumnToProperty) as $column) { $qb->selectAlias('e.' . $column, 'e_' . $column); } /** @var array $relations */ $relations = []; $index = 0; foreach ($entityInfo->propertiesAttributes as $propertyAttributes) { if (!$propertyAttributes->isRelation()) { continue; } $owningTargetClass = $propertyAttributes->getOwningRelationTarget(); if ($owningTargetClass !== null) { $joinColumn = $propertyAttributes->joinColumn; if ($joinColumn === null) { throw new \LogicException('Unreachable: owning relation without a JoinColumn'); } // Owning side (OneToOne's invertedBy, or ManyToOne): the join column lives on our own table. $targetEntityInfo = $this->entityManager->getEntityInfo($owningTargetClass); $alias = 'r' . $index++; $this->joinRelation( $qb, $alias, $targetEntityInfo, 'e.' . $joinColumn->name, $alias . '.' . $joinColumn->referencedColumnName, ); $relations[$alias] = ['attributes' => $propertyAttributes, 'entityInfo' => $targetEntityInfo]; continue; } if ($propertyAttributes->oneToOne !== null && $propertyAttributes->oneToOne->mappedBy !== null) { // Inverse side: the join column lives on the target's table, pointing back at us. $targetEntityInfo = $this->entityManager->getEntityInfo($propertyAttributes->oneToOne->targetEntity); $owningPropertyAttributes = null; foreach ($targetEntityInfo->propertiesAttributes as $candidate) { if ($candidate->property->getName() === $propertyAttributes->oneToOne->mappedBy) { $owningPropertyAttributes = $candidate; break; } } if ($owningPropertyAttributes === null) { continue; } if ($owningPropertyAttributes->joinColumn === null) { continue; } $alias = 'r' . $index++; $this->joinRelation( $qb, $alias, $targetEntityInfo, $alias . '.' . $owningPropertyAttributes->joinColumn->name, 'e.' . $owningPropertyAttributes->joinColumn->referencedColumnName, ); $relations[$alias] = ['attributes' => $propertyAttributes, 'entityInfo' => $targetEntityInfo]; } } return [$qb, $relations]; } private function joinRelation(IQueryBuilder $qb, string $alias, EntityInfo $targetEntityInfo, string $leftExpr, string $rightExpr): void { $qb->leftJoin('e', $targetEntityInfo->tableName, $alias, $qb->expr()->eq($leftExpr, $rightExpr)); foreach (array_keys($targetEntityInfo->mappingColumnToProperty) as $column) { $qb->selectAlias($alias . '.' . $column, $alias . '_' . $column); } } /** * @param array $relations * @param array $row * @return T */ private function mapJoinedRowToEntity(array $relations, mixed $row): object { $mainRow = []; /** @var array> $relationRows */ $relationRows = []; /** @psalm-suppress MixedAssignment $value is a raw, untyped DB driver value. */ foreach ($row as $key => $value) { if (str_starts_with($key, 'e_')) { $mainRow[substr($key, 2)] = $value; continue; } foreach (array_keys($relations) as $alias) { $prefix = $alias . '_'; if (str_starts_with($key, $prefix)) { $relationRows[$alias][substr($key, strlen($prefix))] = $value; continue 2; } } } /** @var T $entity */ $entity = $this->hydrateRow($this->getEntityClass(), $mainRow); foreach ($relations as $alias => $relation) { $propertyName = $relation['attributes']->property->getName(); $targetEntityInfo = $relation['entityInfo']; $idColumn = $targetEntityInfo->mappingPropertyToColumn[$targetEntityInfo->getSingleIdProperty()->getName()]; $relationRow = $relationRows[$alias] ?? []; if (($relationRow[$idColumn] ?? null) === null) { $entity->$propertyName = null; continue; } $entity->$propertyName = $this->hydrateRow($targetEntityInfo->entityClass, $relationRow); } // Safety net for a malformed mapping that never made it into $relations. $entityInfo = $this->entityManager->getEntityInfo($this->getEntityClass()); foreach ($entityInfo->propertiesAttributes as $propertyAttributes) { if (!$propertyAttributes->isRelation()) { continue; } $alreadyResolved = false; foreach ($relations as $relation) { if ($relation['attributes'] === $propertyAttributes) { $alreadyResolved = true; break; } } if (!$alreadyResolved) { $entity->{$propertyAttributes->property->getName()} = null; } } return $entity; } /** * @param array $relations * @return \Generator */ private function yieldJoinedEntities(IQueryBuilder $query, array $relations): \Generator { $result = $query->executeQuery(); try { while ($row = $result->fetch()) { yield $this->mapJoinedRowToEntity($relations, $row); } } finally { $result->closeCursor(); } } /** * @param array $relations * @return T * @throws DoesNotExistException * @throws MultipleObjectsReturnedException */ private function findJoinedEntity(IQueryBuilder $query, array $relations): object { $result = $query->executeQuery(); $row = $result->fetch(); if ($row === false) { $result->closeCursor(); throw new DoesNotExistException($this->buildDebugMessage( 'Did expect one result but found none when executing', $query )); } $row2 = $result->fetch(); $result->closeCursor(); if ($row2 !== false) { throw new MultipleObjectsReturnedException($this->buildDebugMessage( 'Did not expect more than one result when executing', $query )); } return $this->mapJoinedRowToEntity($relations, $row); } /** * Inserts the entity and populates its generated primary key. * * @psalm-param T $entity * @return T * @throws Exception * @since 35.0.0 */ public function insert(object $entity): object { return $this->entityManager->insert($entity); } /** * @psalm-param T $entity * @return T * @since 35.0.0 */ public function update(object $entity): object { return $this->entityManager->update($entity); } /** * @psalm-param T $entity * @since 35.0.0 */ public function delete(object $entity): void { $this->entityManager->delete($entity); } /** * Tries to create a new entry in the db from an entity and * updates an existing entry if duplicate keys are detected * by the database * * @param T $entity the entity that should be created/updated * @return T the saved entity with the (new) id * @throws Exception * @throws \InvalidArgumentException if entity has no id * @since 15.0.0 */ public function insertOrUpdate(object $entity): object { try { return $this->insert($entity); } catch (Exception $exception) { if ($exception->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { return $this->update($entity); } throw $exception; } } /** * Finds entities by a set of criteria, keyed by property name. * * @param array> $criteria * @param array $orderBy * @return \Generator * @since 35.0.0 */ public function findBy(array $criteria, array $orderBy = [], ?int $limit = null, ?int $offset = null): \Generator { [$qb, $relations] = $this->getJoinedSelectQueryBuilder($criteria, $orderBy); if ($limit !== null) { $qb->setMaxResults($limit); } if ($offset !== null) { $qb->setFirstResult($offset); } return $this->yieldJoinedEntities($qb, $relations); } /** * @param array> $criteria * @return int The number of rows deleted * @throws Exception * @since 35.0.0 */ public function deleteBy(array $criteria, ?int $limit = null): int { $entityInfo = $this->entityManager->getEntityInfo($this->getEntityClass()); $qb = $this->connection->getQueryBuilder(); $qb->delete($entityInfo->tableName); foreach ($criteria as $property => $value) { $column = $entityInfo->mappingPropertyToColumn[$property]; $type = $this->entityManager->getParameterType($entityInfo->mappingColumnToTypes[$column], is_array($value)); if ($value === null) { $qb->andWhere($qb->expr()->isNull($column)); } elseif (is_array($value)) { // IN expression $qb->andWhere($qb->expr()->in($column, $qb->createNamedParameter($value, $type))); } else { // = expression $qb->andWhere($qb->expr()->eq($column, $qb->createNamedParameter($value, $type))); } } if ($limit !== null) { $qb->setMaxResults($limit); } return $qb->executeStatement(); } /** * Finds a single entity by a set of criteria, keyed by property name. * * @param array> $criteria * @param array $orderBy * @return T * @throws DoesNotExistException * @since 35.0.0 */ public function findOneBy(array $criteria, array $orderBy = []): object { [$qb, $relations] = $this->getJoinedSelectQueryBuilder($criteria, $orderBy); $qb->setMaxResults(1); return $this->findJoinedEntity($qb, $relations); } /** * @param array> $criteria * @param array $orderBy * @return array{0: IQueryBuilder, 1: array} */ private function getJoinedSelectQueryBuilder(array $criteria, array $orderBy = []): array { $entityInfo = $this->entityManager->getEntityInfo($this->getEntityClass()); [$qb, $relations] = $this->buildJoinedSelectQuery($entityInfo); foreach ($criteria as $property => $value) { $column = $entityInfo->mappingPropertyToColumn[$property]; $type = $this->entityManager->getParameterType($entityInfo->mappingColumnToTypes[$column], is_array($value)); if ($value === null) { $qb->andWhere($qb->expr()->isNull('e.' . $column)); } elseif (is_array($value)) { // IN expression $qb->andWhere($qb->expr()->in('e.' . $column, $qb->createNamedParameter($value, $type))); } else { // = expression $qb->andWhere($qb->expr()->eq('e.' . $column, $qb->createNamedParameter($value, $type))); } } foreach ($orderBy as $field => $direction) { $column = $entityInfo->mappingPropertyToColumn[$field]; $qb->addOrderBy('e.' . $column, $direction); } return [$qb, $relations]; } /** * @return \Generator * @throws Exception * @since 35.0.0 */ public function yieldAll(): \Generator { $entityInfo = $this->entityManager->getEntityInfo($this->getEntityClass()); [$qb, $relations] = $this->buildJoinedSelectQuery($entityInfo); return $this->yieldJoinedEntities($qb, $relations); } /** * @since 35.0.0 */ public function getTableName(): string { $entityInfo = $this->entityManager->getEntityInfo($this->getEntityClass()); return $entityInfo->tableName; } }