From bf6655a534a6775d30cafa67bd801276bda1d98d Mon Sep 17 00:00:00 2001 From: polo Date: Tue, 13 Aug 2024 23:45:21 +0200 Subject: =?UTF-8?q?VERSION=200.2=20doctrine=20ORM=20et=20entit=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Collection/AbstractCollectionPersister.php | 50 + .../Persisters/Collection/CollectionPersister.php | 59 + .../Persisters/Collection/ManyToManyPersister.php | 770 ++++++++ .../Persisters/Collection/OneToManyPersister.php | 264 +++ .../Entity/AbstractEntityInheritancePersister.php | 66 + .../src/Persisters/Entity/BasicEntityPersister.php | 2085 ++++++++++++++++++++ .../Persisters/Entity/CachedPersisterContext.php | 60 + .../orm/src/Persisters/Entity/EntityPersister.php | 298 +++ .../Persisters/Entity/JoinedSubclassPersister.php | 601 ++++++ .../src/Persisters/Entity/SingleTablePersister.php | 166 ++ .../Exception/CantUseInOperatorOnCompositeKeys.php | 15 + .../Persisters/Exception/InvalidOrientation.php | 15 + .../src/Persisters/Exception/UnrecognizedField.php | 24 + .../MatchingAssociationFieldRequiresObject.php | 22 + .../orm/src/Persisters/PersisterException.php | 23 + .../orm/src/Persisters/SqlExpressionVisitor.php | 79 + .../orm/src/Persisters/SqlValueVisitor.php | 88 + 17 files changed, 4685 insertions(+) create mode 100644 vendor/doctrine/orm/src/Persisters/Collection/AbstractCollectionPersister.php create mode 100644 vendor/doctrine/orm/src/Persisters/Collection/CollectionPersister.php create mode 100644 vendor/doctrine/orm/src/Persisters/Collection/ManyToManyPersister.php create mode 100644 vendor/doctrine/orm/src/Persisters/Collection/OneToManyPersister.php create mode 100644 vendor/doctrine/orm/src/Persisters/Entity/AbstractEntityInheritancePersister.php create mode 100644 vendor/doctrine/orm/src/Persisters/Entity/BasicEntityPersister.php create mode 100644 vendor/doctrine/orm/src/Persisters/Entity/CachedPersisterContext.php create mode 100644 vendor/doctrine/orm/src/Persisters/Entity/EntityPersister.php create mode 100644 vendor/doctrine/orm/src/Persisters/Entity/JoinedSubclassPersister.php create mode 100644 vendor/doctrine/orm/src/Persisters/Entity/SingleTablePersister.php create mode 100644 vendor/doctrine/orm/src/Persisters/Exception/CantUseInOperatorOnCompositeKeys.php create mode 100644 vendor/doctrine/orm/src/Persisters/Exception/InvalidOrientation.php create mode 100644 vendor/doctrine/orm/src/Persisters/Exception/UnrecognizedField.php create mode 100644 vendor/doctrine/orm/src/Persisters/MatchingAssociationFieldRequiresObject.php create mode 100644 vendor/doctrine/orm/src/Persisters/PersisterException.php create mode 100644 vendor/doctrine/orm/src/Persisters/SqlExpressionVisitor.php create mode 100644 vendor/doctrine/orm/src/Persisters/SqlValueVisitor.php (limited to 'vendor/doctrine/orm/src/Persisters') diff --git a/vendor/doctrine/orm/src/Persisters/Collection/AbstractCollectionPersister.php b/vendor/doctrine/orm/src/Persisters/Collection/AbstractCollectionPersister.php new file mode 100644 index 0000000..26f0b9e --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Collection/AbstractCollectionPersister.php @@ -0,0 +1,50 @@ +uow = $em->getUnitOfWork(); + $this->conn = $em->getConnection(); + $this->platform = $this->conn->getDatabasePlatform(); + $this->quoteStrategy = $em->getConfiguration()->getQuoteStrategy(); + } + + /** + * Check if entity is in a valid state for operations. + */ + protected function isValidEntityState(object $entity): bool + { + $entityState = $this->uow->getEntityState($entity, UnitOfWork::STATE_NEW); + + if ($entityState === UnitOfWork::STATE_NEW) { + return false; + } + + // If Entity is scheduled for inclusion, it is not in this collection. + // We can assure that because it would have return true before on array check + return ! ($entityState === UnitOfWork::STATE_MANAGED && $this->uow->isScheduledForInsert($entity)); + } +} diff --git a/vendor/doctrine/orm/src/Persisters/Collection/CollectionPersister.php b/vendor/doctrine/orm/src/Persisters/Collection/CollectionPersister.php new file mode 100644 index 0000000..07c4eaf --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Collection/CollectionPersister.php @@ -0,0 +1,59 @@ +getMapping($collection); + + if (! $mapping->isOwningSide()) { + return; // ignore inverse side + } + + assert($mapping->isManyToManyOwningSide()); + + $types = []; + $class = $this->em->getClassMetadata($mapping->sourceEntity); + + foreach ($mapping->joinTable->joinColumns as $joinColumn) { + $types[] = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $class, $this->em); + } + + $this->conn->executeStatement($this->getDeleteSQL($collection), $this->getDeleteSQLParameters($collection), $types); + } + + public function update(PersistentCollection $collection): void + { + $mapping = $this->getMapping($collection); + + if (! $mapping->isOwningSide()) { + return; // ignore inverse side + } + + [$deleteSql, $deleteTypes] = $this->getDeleteRowSQL($collection); + [$insertSql, $insertTypes] = $this->getInsertRowSQL($collection); + + foreach ($collection->getDeleteDiff() as $element) { + $this->conn->executeStatement( + $deleteSql, + $this->getDeleteRowSQLParameters($collection, $element), + $deleteTypes, + ); + } + + foreach ($collection->getInsertDiff() as $element) { + $this->conn->executeStatement( + $insertSql, + $this->getInsertRowSQLParameters($collection, $element), + $insertTypes, + ); + } + } + + public function get(PersistentCollection $collection, mixed $index): object|null + { + $mapping = $this->getMapping($collection); + + if (! $mapping->isIndexed()) { + throw new BadMethodCallException('Selecting a collection by index is only supported on indexed collections.'); + } + + $persister = $this->uow->getEntityPersister($mapping->targetEntity); + $mappedKey = $mapping->isOwningSide() + ? $mapping->inversedBy + : $mapping->mappedBy; + + assert($mappedKey !== null); + + return $persister->load( + [$mappedKey => $collection->getOwner(), $mapping->indexBy() => $index], + null, + $mapping, + [], + LockMode::NONE, + 1, + ); + } + + public function count(PersistentCollection $collection): int + { + $conditions = []; + $params = []; + $types = []; + $mapping = $this->getMapping($collection); + $id = $this->uow->getEntityIdentifier($collection->getOwner()); + $sourceClass = $this->em->getClassMetadata($mapping->sourceEntity); + $association = $this->em->getMetadataFactory()->getOwningSide($mapping); + + $joinTableName = $this->quoteStrategy->getJoinTableName($association, $sourceClass, $this->platform); + $joinColumns = ! $mapping->isOwningSide() + ? $association->joinTable->inverseJoinColumns + : $association->joinTable->joinColumns; + + foreach ($joinColumns as $joinColumn) { + $columnName = $this->quoteStrategy->getJoinColumnName($joinColumn, $sourceClass, $this->platform); + $referencedName = $joinColumn->referencedColumnName; + $conditions[] = 't.' . $columnName . ' = ?'; + $params[] = $id[$sourceClass->getFieldForColumn($referencedName)]; + $types[] = PersisterHelper::getTypeOfColumn($referencedName, $sourceClass, $this->em); + } + + [$joinTargetEntitySQL, $filterSql] = $this->getFilterSql($mapping); + + if ($filterSql) { + $conditions[] = $filterSql; + } + + // If there is a provided criteria, make part of conditions + // @todo Fix this. Current SQL returns something like: + /*if ($criteria && ($expression = $criteria->getWhereExpression()) !== null) { + // A join is needed on the target entity + $targetTableName = $this->quoteStrategy->getTableName($targetClass, $this->platform); + $targetJoinSql = ' JOIN ' . $targetTableName . ' te' + . ' ON' . implode(' AND ', $this->getOnConditionSQL($association)); + + // And criteria conditions needs to be added + $persister = $this->uow->getEntityPersister($targetClass->name); + $visitor = new SqlExpressionVisitor($persister, $targetClass); + $conditions[] = $visitor->dispatch($expression); + + $joinTargetEntitySQL = $targetJoinSql . $joinTargetEntitySQL; + }*/ + + $sql = 'SELECT COUNT(*)' + . ' FROM ' . $joinTableName . ' t' + . $joinTargetEntitySQL + . ' WHERE ' . implode(' AND ', $conditions); + + return (int) $this->conn->fetchOne($sql, $params, $types); + } + + /** + * {@inheritDoc} + */ + public function slice(PersistentCollection $collection, int $offset, int|null $length = null): array + { + $mapping = $this->getMapping($collection); + $persister = $this->uow->getEntityPersister($mapping->targetEntity); + + return $persister->getManyToManyCollection($mapping, $collection->getOwner(), $offset, $length); + } + + public function containsKey(PersistentCollection $collection, mixed $key): bool + { + $mapping = $this->getMapping($collection); + + if (! $mapping->isIndexed()) { + throw new BadMethodCallException('Selecting a collection by index is only supported on indexed collections.'); + } + + [$quotedJoinTable, $whereClauses, $params, $types] = $this->getJoinTableRestrictionsWithKey( + $collection, + (string) $key, + true, + ); + + $sql = 'SELECT 1 FROM ' . $quotedJoinTable . ' WHERE ' . implode(' AND ', $whereClauses); + + return (bool) $this->conn->fetchOne($sql, $params, $types); + } + + public function contains(PersistentCollection $collection, object $element): bool + { + if (! $this->isValidEntityState($element)) { + return false; + } + + [$quotedJoinTable, $whereClauses, $params, $types] = $this->getJoinTableRestrictions( + $collection, + $element, + true, + ); + + $sql = 'SELECT 1 FROM ' . $quotedJoinTable . ' WHERE ' . implode(' AND ', $whereClauses); + + return (bool) $this->conn->fetchOne($sql, $params, $types); + } + + /** + * {@inheritDoc} + */ + public function loadCriteria(PersistentCollection $collection, Criteria $criteria): array + { + $mapping = $this->getMapping($collection); + $owner = $collection->getOwner(); + $ownerMetadata = $this->em->getClassMetadata($owner::class); + $id = $this->uow->getEntityIdentifier($owner); + $targetClass = $this->em->getClassMetadata($mapping->targetEntity); + $onConditions = $this->getOnConditionSQL($mapping); + $whereClauses = $params = []; + $paramTypes = []; + + if (! $mapping->isOwningSide()) { + assert($mapping instanceof InverseSideMapping); + $associationSourceClass = $targetClass; + $sourceRelationMode = 'relationToTargetKeyColumns'; + } else { + $associationSourceClass = $ownerMetadata; + $sourceRelationMode = 'relationToSourceKeyColumns'; + } + + $mapping = $this->em->getMetadataFactory()->getOwningSide($mapping); + + foreach ($mapping->$sourceRelationMode as $key => $value) { + $whereClauses[] = sprintf('t.%s = ?', $key); + $params[] = $ownerMetadata->containsForeignIdentifier + ? $id[$ownerMetadata->getFieldForColumn($value)] + : $id[$ownerMetadata->fieldNames[$value]]; + $paramTypes[] = PersisterHelper::getTypeOfColumn($value, $ownerMetadata, $this->em); + } + + $parameters = $this->expandCriteriaParameters($criteria); + + foreach ($parameters as $parameter) { + [$name, $value, $operator] = $parameter; + + $field = $this->quoteStrategy->getColumnName($name, $targetClass, $this->platform); + + if ($value === null && ($operator === Comparison::EQ || $operator === Comparison::NEQ)) { + $whereClauses[] = sprintf('te.%s %s NULL', $field, $operator === Comparison::EQ ? 'IS' : 'IS NOT'); + } else { + $whereClauses[] = sprintf('te.%s %s ?', $field, $operator); + $params[] = $value; + $paramTypes[] = PersisterHelper::getTypeOfField($name, $targetClass, $this->em)[0]; + } + } + + $tableName = $this->quoteStrategy->getTableName($targetClass, $this->platform); + $joinTable = $this->quoteStrategy->getJoinTableName($mapping, $associationSourceClass, $this->platform); + + $rsm = new Query\ResultSetMappingBuilder($this->em); + $rsm->addRootEntityFromClassMetadata($targetClass->name, 'te'); + + $sql = 'SELECT ' . $rsm->generateSelectClause() + . ' FROM ' . $tableName . ' te' + . ' JOIN ' . $joinTable . ' t ON' + . implode(' AND ', $onConditions) + . ' WHERE ' . implode(' AND ', $whereClauses); + + $sql .= $this->getOrderingSql($criteria, $targetClass); + + $sql .= $this->getLimitSql($criteria); + + $stmt = $this->conn->executeQuery($sql, $params, $paramTypes); + + return $this + ->em + ->newHydrator(Query::HYDRATE_OBJECT) + ->hydrateAll($stmt, $rsm); + } + + /** + * Generates the filter SQL for a given mapping. + * + * This method is not used for actually grabbing the related entities + * but when the extra-lazy collection methods are called on a filtered + * association. This is why besides the many to many table we also + * have to join in the actual entities table leading to additional + * JOIN. + * + * @param AssociationMapping $mapping Array containing mapping information. + * + * @return string[] ordered tuple: + * - JOIN condition to add to the SQL + * - WHERE condition to add to the SQL + * @psalm-return array{0: string, 1: string} + */ + public function getFilterSql(AssociationMapping $mapping): array + { + $targetClass = $this->em->getClassMetadata($mapping->targetEntity); + $rootClass = $this->em->getClassMetadata($targetClass->rootEntityName); + $filterSql = $this->generateFilterConditionSQL($rootClass, 'te'); + + if ($filterSql === '') { + return ['', '']; + } + + // A join is needed if there is filtering on the target entity + $tableName = $this->quoteStrategy->getTableName($rootClass, $this->platform); + $joinSql = ' JOIN ' . $tableName . ' te' + . ' ON' . implode(' AND ', $this->getOnConditionSQL($mapping)); + + return [$joinSql, $filterSql]; + } + + /** + * Generates the filter SQL for a given entity and table alias. + * + * @param ClassMetadata $targetEntity Metadata of the target entity. + * @param string $targetTableAlias The table alias of the joined/selected table. + * + * @return string The SQL query part to add to a query. + */ + protected function generateFilterConditionSQL(ClassMetadata $targetEntity, string $targetTableAlias): string + { + $filterClauses = []; + + foreach ($this->em->getFilters()->getEnabledFilters() as $filter) { + $filterExpr = $filter->addFilterConstraint($targetEntity, $targetTableAlias); + if ($filterExpr) { + $filterClauses[] = '(' . $filterExpr . ')'; + } + } + + return $filterClauses + ? '(' . implode(' AND ', $filterClauses) . ')' + : ''; + } + + /** + * Generate ON condition + * + * @return string[] + * @psalm-return list + */ + protected function getOnConditionSQL(AssociationMapping $mapping): array + { + $association = $this->em->getMetadataFactory()->getOwningSide($mapping); + $joinColumns = $mapping->isOwningSide() + ? $association->joinTable->inverseJoinColumns + : $association->joinTable->joinColumns; + + $conditions = []; + + $targetClass = $this->em->getClassMetadata($mapping->targetEntity); + foreach ($joinColumns as $joinColumn) { + $joinColumnName = $this->quoteStrategy->getJoinColumnName($joinColumn, $targetClass, $this->platform); + $refColumnName = $this->quoteStrategy->getReferencedJoinColumnName($joinColumn, $targetClass, $this->platform); + + $conditions[] = ' t.' . $joinColumnName . ' = te.' . $refColumnName; + } + + return $conditions; + } + + protected function getDeleteSQL(PersistentCollection $collection): string + { + $columns = []; + $mapping = $this->getMapping($collection); + assert($mapping->isManyToManyOwningSide()); + $class = $this->em->getClassMetadata($collection->getOwner()::class); + $joinTable = $this->quoteStrategy->getJoinTableName($mapping, $class, $this->platform); + + foreach ($mapping->joinTable->joinColumns as $joinColumn) { + $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $class, $this->platform); + } + + return 'DELETE FROM ' . $joinTable + . ' WHERE ' . implode(' = ? AND ', $columns) . ' = ?'; + } + + /** + * Internal note: Order of the parameters must be the same as the order of the columns in getDeleteSql. + * + * @return list + */ + protected function getDeleteSQLParameters(PersistentCollection $collection): array + { + $mapping = $this->getMapping($collection); + assert($mapping->isManyToManyOwningSide()); + $identifier = $this->uow->getEntityIdentifier($collection->getOwner()); + + // Optimization for single column identifier + if (count($mapping->relationToSourceKeyColumns) === 1) { + return [reset($identifier)]; + } + + // Composite identifier + $sourceClass = $this->em->getClassMetadata($mapping->sourceEntity); + $params = []; + + foreach ($mapping->relationToSourceKeyColumns as $columnName => $refColumnName) { + $params[] = isset($sourceClass->fieldNames[$refColumnName]) + ? $identifier[$sourceClass->fieldNames[$refColumnName]] + : $identifier[$sourceClass->getFieldForColumn($refColumnName)]; + } + + return $params; + } + + /** + * Gets the SQL statement used for deleting a row from the collection. + * + * @return string[]|string[][] ordered tuple containing the SQL to be executed and an array + * of types for bound parameters + * @psalm-return array{0: string, 1: list} + */ + protected function getDeleteRowSQL(PersistentCollection $collection): array + { + $mapping = $this->getMapping($collection); + assert($mapping->isManyToManyOwningSide()); + $class = $this->em->getClassMetadata($mapping->sourceEntity); + $targetClass = $this->em->getClassMetadata($mapping->targetEntity); + $columns = []; + $types = []; + + foreach ($mapping->joinTable->joinColumns as $joinColumn) { + $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $class, $this->platform); + $types[] = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $class, $this->em); + } + + foreach ($mapping->joinTable->inverseJoinColumns as $joinColumn) { + $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $targetClass, $this->platform); + $types[] = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $targetClass, $this->em); + } + + return [ + 'DELETE FROM ' . $this->quoteStrategy->getJoinTableName($mapping, $class, $this->platform) + . ' WHERE ' . implode(' = ? AND ', $columns) . ' = ?', + $types, + ]; + } + + /** + * Gets the SQL parameters for the corresponding SQL statement to delete the given + * element from the given collection. + * + * Internal note: Order of the parameters must be the same as the order of the columns in getDeleteRowSql. + * + * @return mixed[] + * @psalm-return list + */ + protected function getDeleteRowSQLParameters(PersistentCollection $collection, object $element): array + { + return $this->collectJoinTableColumnParameters($collection, $element); + } + + /** + * Gets the SQL statement used for inserting a row in the collection. + * + * @return string[]|string[][] ordered tuple containing the SQL to be executed and an array + * of types for bound parameters + * @psalm-return array{0: string, 1: list} + */ + protected function getInsertRowSQL(PersistentCollection $collection): array + { + $columns = []; + $types = []; + $mapping = $this->getMapping($collection); + assert($mapping->isManyToManyOwningSide()); + $class = $this->em->getClassMetadata($mapping->sourceEntity); + $targetClass = $this->em->getClassMetadata($mapping->targetEntity); + + foreach ($mapping->joinTable->joinColumns as $joinColumn) { + $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $targetClass, $this->platform); + $types[] = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $class, $this->em); + } + + foreach ($mapping->joinTable->inverseJoinColumns as $joinColumn) { + $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $targetClass, $this->platform); + $types[] = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $targetClass, $this->em); + } + + return [ + 'INSERT INTO ' . $this->quoteStrategy->getJoinTableName($mapping, $class, $this->platform) + . ' (' . implode(', ', $columns) . ')' + . ' VALUES' + . ' (' . implode(', ', array_fill(0, count($columns), '?')) . ')', + $types, + ]; + } + + /** + * Gets the SQL parameters for the corresponding SQL statement to insert the given + * element of the given collection into the database. + * + * Internal note: Order of the parameters must be the same as the order of the columns in getInsertRowSql. + * + * @return mixed[] + * @psalm-return list + */ + protected function getInsertRowSQLParameters(PersistentCollection $collection, object $element): array + { + return $this->collectJoinTableColumnParameters($collection, $element); + } + + /** + * Collects the parameters for inserting/deleting on the join table in the order + * of the join table columns as specified in ManyToManyMapping#joinTableColumns. + * + * @return mixed[] + * @psalm-return list + */ + private function collectJoinTableColumnParameters( + PersistentCollection $collection, + object $element, + ): array { + $params = []; + $mapping = $this->getMapping($collection); + assert($mapping->isManyToManyOwningSide()); + $isComposite = count($mapping->joinTableColumns) > 2; + + $identifier1 = $this->uow->getEntityIdentifier($collection->getOwner()); + $identifier2 = $this->uow->getEntityIdentifier($element); + + $class1 = $class2 = null; + if ($isComposite) { + $class1 = $this->em->getClassMetadata($collection->getOwner()::class); + $class2 = $collection->getTypeClass(); + } + + foreach ($mapping->joinTableColumns as $joinTableColumn) { + $isRelationToSource = isset($mapping->relationToSourceKeyColumns[$joinTableColumn]); + + if (! $isComposite) { + $params[] = $isRelationToSource ? array_pop($identifier1) : array_pop($identifier2); + + continue; + } + + if ($isRelationToSource) { + $params[] = $identifier1[$class1->getFieldForColumn($mapping->relationToSourceKeyColumns[$joinTableColumn])]; + + continue; + } + + $params[] = $identifier2[$class2->getFieldForColumn($mapping->relationToTargetKeyColumns[$joinTableColumn])]; + } + + return $params; + } + + /** + * @param bool $addFilters Whether the filter SQL should be included or not. + * + * @return mixed[] ordered vector: + * - quoted join table name + * - where clauses to be added for filtering + * - parameters to be bound for filtering + * - types of the parameters to be bound for filtering + * @psalm-return array{0: string, 1: list, 2: list, 3: list} + */ + private function getJoinTableRestrictionsWithKey( + PersistentCollection $collection, + string $key, + bool $addFilters, + ): array { + $filterMapping = $this->getMapping($collection); + $mapping = $filterMapping; + $indexBy = $mapping->indexBy(); + $id = $this->uow->getEntityIdentifier($collection->getOwner()); + $sourceClass = $this->em->getClassMetadata($mapping->sourceEntity); + $targetClass = $this->em->getClassMetadata($mapping->targetEntity); + + if (! $mapping->isOwningSide()) { + assert($mapping instanceof InverseSideMapping); + $associationSourceClass = $this->em->getClassMetadata($mapping->targetEntity); + $mapping = $associationSourceClass->associationMappings[$mapping->mappedBy]; + assert($mapping->isManyToManyOwningSide()); + $joinColumns = $mapping->joinTable->joinColumns; + $sourceRelationMode = 'relationToTargetKeyColumns'; + $targetRelationMode = 'relationToSourceKeyColumns'; + } else { + assert($mapping->isManyToManyOwningSide()); + $associationSourceClass = $this->em->getClassMetadata($mapping->sourceEntity); + $joinColumns = $mapping->joinTable->inverseJoinColumns; + $sourceRelationMode = 'relationToSourceKeyColumns'; + $targetRelationMode = 'relationToTargetKeyColumns'; + } + + $quotedJoinTable = $this->quoteStrategy->getJoinTableName($mapping, $associationSourceClass, $this->platform) . ' t'; + $whereClauses = []; + $params = []; + $types = []; + + $joinNeeded = ! in_array($indexBy, $targetClass->identifier, true); + + if ($joinNeeded) { // extra join needed if indexBy is not a @id + $joinConditions = []; + + foreach ($joinColumns as $joinTableColumn) { + $joinConditions[] = 't.' . $joinTableColumn->name . ' = tr.' . $joinTableColumn->referencedColumnName; + } + + $tableName = $this->quoteStrategy->getTableName($targetClass, $this->platform); + $quotedJoinTable .= ' JOIN ' . $tableName . ' tr ON ' . implode(' AND ', $joinConditions); + $columnName = $targetClass->getColumnName($indexBy); + + $whereClauses[] = 'tr.' . $columnName . ' = ?'; + $params[] = $key; + $types[] = PersisterHelper::getTypeOfColumn($columnName, $targetClass, $this->em); + } + + foreach ($mapping->joinTableColumns as $joinTableColumn) { + if (isset($mapping->{$sourceRelationMode}[$joinTableColumn])) { + $column = $mapping->{$sourceRelationMode}[$joinTableColumn]; + $whereClauses[] = 't.' . $joinTableColumn . ' = ?'; + $params[] = $sourceClass->containsForeignIdentifier + ? $id[$sourceClass->getFieldForColumn($column)] + : $id[$sourceClass->fieldNames[$column]]; + $types[] = PersisterHelper::getTypeOfColumn($column, $sourceClass, $this->em); + } elseif (! $joinNeeded) { + $column = $mapping->{$targetRelationMode}[$joinTableColumn]; + + $whereClauses[] = 't.' . $joinTableColumn . ' = ?'; + $params[] = $key; + $types[] = PersisterHelper::getTypeOfColumn($column, $targetClass, $this->em); + } + } + + if ($addFilters) { + [$joinTargetEntitySQL, $filterSql] = $this->getFilterSql($filterMapping); + + if ($filterSql) { + $quotedJoinTable .= ' ' . $joinTargetEntitySQL; + $whereClauses[] = $filterSql; + } + } + + return [$quotedJoinTable, $whereClauses, $params, $types]; + } + + /** + * @param bool $addFilters Whether the filter SQL should be included or not. + * + * @return mixed[] ordered vector: + * - quoted join table name + * - where clauses to be added for filtering + * - parameters to be bound for filtering + * - types of the parameters to be bound for filtering + * @psalm-return array{0: string, 1: list, 2: list, 3: list} + */ + private function getJoinTableRestrictions( + PersistentCollection $collection, + object $element, + bool $addFilters, + ): array { + $filterMapping = $this->getMapping($collection); + $mapping = $filterMapping; + + if (! $mapping->isOwningSide()) { + $sourceClass = $this->em->getClassMetadata($mapping->targetEntity); + $targetClass = $this->em->getClassMetadata($mapping->sourceEntity); + $sourceId = $this->uow->getEntityIdentifier($element); + $targetId = $this->uow->getEntityIdentifier($collection->getOwner()); + } else { + $sourceClass = $this->em->getClassMetadata($mapping->sourceEntity); + $targetClass = $this->em->getClassMetadata($mapping->targetEntity); + $sourceId = $this->uow->getEntityIdentifier($collection->getOwner()); + $targetId = $this->uow->getEntityIdentifier($element); + } + + $mapping = $this->em->getMetadataFactory()->getOwningSide($mapping); + + $quotedJoinTable = $this->quoteStrategy->getJoinTableName($mapping, $sourceClass, $this->platform); + $whereClauses = []; + $params = []; + $types = []; + + foreach ($mapping->joinTableColumns as $joinTableColumn) { + $whereClauses[] = ($addFilters ? 't.' : '') . $joinTableColumn . ' = ?'; + + if (isset($mapping->relationToTargetKeyColumns[$joinTableColumn])) { + $targetColumn = $mapping->relationToTargetKeyColumns[$joinTableColumn]; + $params[] = $targetId[$targetClass->getFieldForColumn($targetColumn)]; + $types[] = PersisterHelper::getTypeOfColumn($targetColumn, $targetClass, $this->em); + + continue; + } + + // relationToSourceKeyColumns + $targetColumn = $mapping->relationToSourceKeyColumns[$joinTableColumn]; + $params[] = $sourceId[$sourceClass->getFieldForColumn($targetColumn)]; + $types[] = PersisterHelper::getTypeOfColumn($targetColumn, $sourceClass, $this->em); + } + + if ($addFilters) { + $quotedJoinTable .= ' t'; + + [$joinTargetEntitySQL, $filterSql] = $this->getFilterSql($filterMapping); + + if ($filterSql) { + $quotedJoinTable .= ' ' . $joinTargetEntitySQL; + $whereClauses[] = $filterSql; + } + } + + return [$quotedJoinTable, $whereClauses, $params, $types]; + } + + /** + * Expands Criteria Parameters by walking the expressions and grabbing all + * parameters and types from it. + * + * @return mixed[][] + */ + private function expandCriteriaParameters(Criteria $criteria): array + { + $expression = $criteria->getWhereExpression(); + + if ($expression === null) { + return []; + } + + $valueVisitor = new SqlValueVisitor(); + + $valueVisitor->dispatch($expression); + + [, $types] = $valueVisitor->getParamsAndTypes(); + + return $types; + } + + private function getOrderingSql(Criteria $criteria, ClassMetadata $targetClass): string + { + $orderings = $criteria->orderings(); + if ($orderings) { + $orderBy = []; + foreach ($orderings as $name => $direction) { + $field = $this->quoteStrategy->getColumnName( + $name, + $targetClass, + $this->platform, + ); + $orderBy[] = $field . ' ' . $direction->value; + } + + return ' ORDER BY ' . implode(', ', $orderBy); + } + + return ''; + } + + /** @throws DBALException */ + private function getLimitSql(Criteria $criteria): string + { + $limit = $criteria->getMaxResults(); + $offset = $criteria->getFirstResult(); + + return $this->platform->modifyLimitQuery('', $limit, $offset ?? 0); + } + + private function getMapping(PersistentCollection $collection): AssociationMapping&ManyToManyAssociationMapping + { + $mapping = $collection->getMapping(); + + assert($mapping instanceof ManyToManyAssociationMapping); + + return $mapping; + } +} diff --git a/vendor/doctrine/orm/src/Persisters/Collection/OneToManyPersister.php b/vendor/doctrine/orm/src/Persisters/Collection/OneToManyPersister.php new file mode 100644 index 0000000..0727b1f --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Collection/OneToManyPersister.php @@ -0,0 +1,264 @@ +getMapping($collection); + + if (! $mapping->orphanRemoval) { + // Handling non-orphan removal should never happen, as @OneToMany + // can only be inverse side. For owning side one to many, it is + // required to have a join table, which would classify as a ManyToManyPersister. + return; + } + + $targetClass = $this->em->getClassMetadata($mapping->targetEntity); + + $targetClass->isInheritanceTypeJoined() + ? $this->deleteJoinedEntityCollection($collection) + : $this->deleteEntityCollection($collection); + } + + public function update(PersistentCollection $collection): void + { + // This can never happen. One to many can only be inverse side. + // For owning side one to many, it is required to have a join table, + // then classifying it as a ManyToManyPersister. + return; + } + + public function get(PersistentCollection $collection, mixed $index): object|null + { + $mapping = $this->getMapping($collection); + + if (! $mapping->isIndexed()) { + throw new BadMethodCallException('Selecting a collection by index is only supported on indexed collections.'); + } + + $persister = $this->uow->getEntityPersister($mapping->targetEntity); + + return $persister->load( + [ + $mapping->mappedBy => $collection->getOwner(), + $mapping->indexBy() => $index, + ], + null, + $mapping, + [], + null, + 1, + ); + } + + public function count(PersistentCollection $collection): int + { + $mapping = $this->getMapping($collection); + $persister = $this->uow->getEntityPersister($mapping->targetEntity); + + // only works with single id identifier entities. Will throw an + // exception in Entity Persisters if that is not the case for the + // 'mappedBy' field. + $criteria = new Criteria(Criteria::expr()->eq($mapping->mappedBy, $collection->getOwner())); + + return $persister->count($criteria); + } + + /** + * {@inheritDoc} + */ + public function slice(PersistentCollection $collection, int $offset, int|null $length = null): array + { + $mapping = $this->getMapping($collection); + $persister = $this->uow->getEntityPersister($mapping->targetEntity); + + return $persister->getOneToManyCollection($mapping, $collection->getOwner(), $offset, $length); + } + + public function containsKey(PersistentCollection $collection, mixed $key): bool + { + $mapping = $this->getMapping($collection); + + if (! $mapping->isIndexed()) { + throw new BadMethodCallException('Selecting a collection by index is only supported on indexed collections.'); + } + + $persister = $this->uow->getEntityPersister($mapping->targetEntity); + + // only works with single id identifier entities. Will throw an + // exception in Entity Persisters if that is not the case for the + // 'mappedBy' field. + $criteria = new Criteria(); + + $criteria->andWhere(Criteria::expr()->eq($mapping->mappedBy, $collection->getOwner())); + $criteria->andWhere(Criteria::expr()->eq($mapping->indexBy(), $key)); + + return (bool) $persister->count($criteria); + } + + public function contains(PersistentCollection $collection, object $element): bool + { + if (! $this->isValidEntityState($element)) { + return false; + } + + $mapping = $this->getMapping($collection); + $persister = $this->uow->getEntityPersister($mapping->targetEntity); + + // only works with single id identifier entities. Will throw an + // exception in Entity Persisters if that is not the case for the + // 'mappedBy' field. + $criteria = new Criteria(Criteria::expr()->eq($mapping->mappedBy, $collection->getOwner())); + + return $persister->exists($element, $criteria); + } + + /** + * {@inheritDoc} + */ + public function loadCriteria(PersistentCollection $collection, Criteria $criteria): array + { + throw new BadMethodCallException('Filtering a collection by Criteria is not supported by this CollectionPersister.'); + } + + /** + * @throws DBALException + * @throws EntityNotFoundException + * @throws MappingException + */ + private function deleteEntityCollection(PersistentCollection $collection): int + { + $mapping = $this->getMapping($collection); + $identifier = $this->uow->getEntityIdentifier($collection->getOwner()); + $sourceClass = $this->em->getClassMetadata($mapping->sourceEntity); + $targetClass = $this->em->getClassMetadata($mapping->targetEntity); + $columns = []; + $parameters = []; + $types = []; + + foreach ($this->em->getMetadataFactory()->getOwningSide($mapping)->joinColumns as $joinColumn) { + $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $targetClass, $this->platform); + $parameters[] = $identifier[$sourceClass->getFieldForColumn($joinColumn->referencedColumnName)]; + $types[] = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $sourceClass, $this->em); + } + + $statement = 'DELETE FROM ' . $this->quoteStrategy->getTableName($targetClass, $this->platform) + . ' WHERE ' . implode(' = ? AND ', $columns) . ' = ?'; + + if ($targetClass->isInheritanceTypeSingleTable()) { + $discriminatorColumn = $targetClass->getDiscriminatorColumn(); + $statement .= ' AND ' . $discriminatorColumn->name . ' = ?'; + $parameters[] = $targetClass->discriminatorValue; + $types[] = $discriminatorColumn->type; + } + + $numAffected = $this->conn->executeStatement($statement, $parameters, $types); + + assert(is_int($numAffected)); + + return $numAffected; + } + + /** + * Delete Class Table Inheritance entities. + * A temporary table is needed to keep IDs to be deleted in both parent and child class' tables. + * + * Thanks Steve Ebersole (Hibernate) for idea on how to tackle reliably this scenario, we owe him a beer! =) + * + * @throws DBALException + */ + private function deleteJoinedEntityCollection(PersistentCollection $collection): int + { + $mapping = $this->getMapping($collection); + $sourceClass = $this->em->getClassMetadata($mapping->sourceEntity); + $targetClass = $this->em->getClassMetadata($mapping->targetEntity); + $rootClass = $this->em->getClassMetadata($targetClass->rootEntityName); + + // 1) Build temporary table DDL + $tempTable = $this->platform->getTemporaryTableName($rootClass->getTemporaryIdTableName()); + $idColumnNames = $rootClass->getIdentifierColumnNames(); + $idColumnList = implode(', ', $idColumnNames); + $columnDefinitions = []; + + foreach ($idColumnNames as $idColumnName) { + $columnDefinitions[$idColumnName] = [ + 'name' => $idColumnName, + 'notnull' => true, + 'type' => Type::getType(PersisterHelper::getTypeOfColumn($idColumnName, $rootClass, $this->em)), + ]; + } + + $statement = $this->platform->getCreateTemporaryTableSnippetSQL() . ' ' . $tempTable + . ' (' . $this->platform->getColumnDeclarationListSQL($columnDefinitions) . ')'; + + $this->conn->executeStatement($statement); + + // 2) Build insert table records into temporary table + $query = $this->em->createQuery( + ' SELECT t0.' . implode(', t0.', $rootClass->getIdentifierFieldNames()) + . ' FROM ' . $targetClass->name . ' t0 WHERE t0.' . $mapping->mappedBy . ' = :owner', + )->setParameter('owner', $collection->getOwner()); + + $sql = $query->getSQL(); + assert(is_string($sql)); + $statement = 'INSERT INTO ' . $tempTable . ' (' . $idColumnList . ') ' . $sql; + $parameters = array_values($sourceClass->getIdentifierValues($collection->getOwner())); + $numDeleted = $this->conn->executeStatement($statement, $parameters); + + // 3) Delete records on each table in the hierarchy + $classNames = [...$targetClass->parentClasses, ...[$targetClass->name], ...$targetClass->subClasses]; + + foreach (array_reverse($classNames) as $className) { + $tableName = $this->quoteStrategy->getTableName($this->em->getClassMetadata($className), $this->platform); + $statement = 'DELETE FROM ' . $tableName . ' WHERE (' . $idColumnList . ')' + . ' IN (SELECT ' . $idColumnList . ' FROM ' . $tempTable . ')'; + + $this->conn->executeStatement($statement); + } + + // 4) Drop temporary table + $statement = $this->platform->getDropTemporaryTableSQL($tempTable); + + $this->conn->executeStatement($statement); + + assert(is_int($numDeleted)); + + return $numDeleted; + } + + private function getMapping(PersistentCollection $collection): OneToManyAssociationMapping + { + $mapping = $collection->getMapping(); + + assert($mapping->isOneToMany()); + + return $mapping; + } +} diff --git a/vendor/doctrine/orm/src/Persisters/Entity/AbstractEntityInheritancePersister.php b/vendor/doctrine/orm/src/Persisters/Entity/AbstractEntityInheritancePersister.php new file mode 100644 index 0000000..cf8a74e --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Entity/AbstractEntityInheritancePersister.php @@ -0,0 +1,66 @@ +class->getDiscriminatorColumn(); + $this->columnTypes[$discColumn->name] = $discColumn->type; + $data[$this->getDiscriminatorColumnTableName()][$discColumn->name] = $this->class->discriminatorValue; + + return $data; + } + + /** + * Gets the name of the table that contains the discriminator column. + */ + abstract protected function getDiscriminatorColumnTableName(): string; + + protected function getSelectColumnSQL(string $field, ClassMetadata $class, string $alias = 'r'): string + { + $tableAlias = $alias === 'r' ? '' : $alias; + $fieldMapping = $class->fieldMappings[$field]; + $columnAlias = $this->getSQLColumnAlias($fieldMapping->columnName); + $sql = sprintf( + '%s.%s', + $this->getSQLTableAlias($class->name, $tableAlias), + $this->quoteStrategy->getColumnName($field, $class, $this->platform), + ); + + $this->currentPersisterContext->rsm->addFieldResult($alias, $columnAlias, $field, $class->name); + + $type = Type::getType($fieldMapping->type); + $sql = $type->convertToPHPValueSQL($sql, $this->platform); + + return $sql . ' AS ' . $columnAlias; + } + + protected function getSelectJoinColumnSQL(string $tableAlias, string $joinColumnName, string $quotedColumnName, string $type): string + { + $columnAlias = $this->getSQLColumnAlias($joinColumnName); + + $this->currentPersisterContext->rsm->addMetaResult('r', $columnAlias, $joinColumnName, false, $type); + + return $tableAlias . '.' . $quotedColumnName . ' AS ' . $columnAlias; + } +} diff --git a/vendor/doctrine/orm/src/Persisters/Entity/BasicEntityPersister.php b/vendor/doctrine/orm/src/Persisters/Entity/BasicEntityPersister.php new file mode 100644 index 0000000..377e03c --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Entity/BasicEntityPersister.php @@ -0,0 +1,2085 @@ + */ + private static array $comparisonMap = [ + Comparison::EQ => '= %s', + Comparison::NEQ => '!= %s', + Comparison::GT => '> %s', + Comparison::GTE => '>= %s', + Comparison::LT => '< %s', + Comparison::LTE => '<= %s', + Comparison::IN => 'IN (%s)', + Comparison::NIN => 'NOT IN (%s)', + Comparison::CONTAINS => 'LIKE %s', + Comparison::STARTS_WITH => 'LIKE %s', + Comparison::ENDS_WITH => 'LIKE %s', + ]; + + /** + * The underlying DBAL Connection of the used EntityManager. + */ + protected Connection $conn; + + /** + * The database platform. + */ + protected AbstractPlatform $platform; + + /** + * Queued inserts. + * + * @psalm-var array + */ + protected array $queuedInserts = []; + + /** + * The map of column names to DBAL mapping types of all prepared columns used + * when INSERTing or UPDATEing an entity. + * + * @see prepareInsertData($entity) + * @see prepareUpdateData($entity) + * + * @var mixed[] + */ + protected array $columnTypes = []; + + /** + * The map of quoted column names. + * + * @see prepareInsertData($entity) + * @see prepareUpdateData($entity) + * + * @var mixed[] + */ + protected array $quotedColumns = []; + + /** + * The INSERT SQL statement used for entities handled by this persister. + * This SQL is only generated once per request, if at all. + */ + private string|null $insertSql = null; + + /** + * The quote strategy. + */ + protected QuoteStrategy $quoteStrategy; + + /** + * The IdentifierFlattener used for manipulating identifiers + */ + protected readonly IdentifierFlattener $identifierFlattener; + + protected CachedPersisterContext $currentPersisterContext; + private readonly CachedPersisterContext $limitsHandlingContext; + private readonly CachedPersisterContext $noLimitsContext; + + /** + * Initializes a new BasicEntityPersister that uses the given EntityManager + * and persists instances of the class described by the given ClassMetadata descriptor. + * + * @param ClassMetadata $class Metadata object that describes the mapping of the mapped entity class. + */ + public function __construct( + protected EntityManagerInterface $em, + protected ClassMetadata $class, + ) { + $this->conn = $em->getConnection(); + $this->platform = $this->conn->getDatabasePlatform(); + $this->quoteStrategy = $em->getConfiguration()->getQuoteStrategy(); + $this->identifierFlattener = new IdentifierFlattener($em->getUnitOfWork(), $em->getMetadataFactory()); + $this->noLimitsContext = $this->currentPersisterContext = new CachedPersisterContext( + $class, + new Query\ResultSetMapping(), + false, + ); + $this->limitsHandlingContext = new CachedPersisterContext( + $class, + new Query\ResultSetMapping(), + true, + ); + } + + public function getClassMetadata(): ClassMetadata + { + return $this->class; + } + + public function getResultSetMapping(): ResultSetMapping + { + return $this->currentPersisterContext->rsm; + } + + public function addInsert(object $entity): void + { + $this->queuedInserts[spl_object_id($entity)] = $entity; + } + + /** + * {@inheritDoc} + */ + public function getInserts(): array + { + return $this->queuedInserts; + } + + public function executeInserts(): void + { + if (! $this->queuedInserts) { + return; + } + + $uow = $this->em->getUnitOfWork(); + $idGenerator = $this->class->idGenerator; + $isPostInsertId = $idGenerator->isPostInsertGenerator(); + + $stmt = $this->conn->prepare($this->getInsertSQL()); + $tableName = $this->class->getTableName(); + + foreach ($this->queuedInserts as $key => $entity) { + $insertData = $this->prepareInsertData($entity); + + if (isset($insertData[$tableName])) { + $paramIndex = 1; + + foreach ($insertData[$tableName] as $column => $value) { + $stmt->bindValue($paramIndex++, $value, $this->columnTypes[$column]); + } + } + + $stmt->executeStatement(); + + if ($isPostInsertId) { + $generatedId = $idGenerator->generateId($this->em, $entity); + $id = [$this->class->identifier[0] => $generatedId]; + + $uow->assignPostInsertId($entity, $generatedId); + } else { + $id = $this->class->getIdentifierValues($entity); + } + + if ($this->class->requiresFetchAfterChange) { + $this->assignDefaultVersionAndUpsertableValues($entity, $id); + } + + // Unset this queued insert, so that the prepareUpdateData() method knows right away + // (for the next entity already) that the current entity has been written to the database + // and no extra updates need to be scheduled to refer to it. + // + // In \Doctrine\ORM\UnitOfWork::executeInserts(), the UoW already removed entities + // from its own list (\Doctrine\ORM\UnitOfWork::$entityInsertions) right after they + // were given to our addInsert() method. + unset($this->queuedInserts[$key]); + } + } + + /** + * Retrieves the default version value which was created + * by the preceding INSERT statement and assigns it back in to the + * entities version field if the given entity is versioned. + * Also retrieves values of columns marked as 'non insertable' and / or + * 'not updatable' and assigns them back to the entities corresponding fields. + * + * @param mixed[] $id + */ + protected function assignDefaultVersionAndUpsertableValues(object $entity, array $id): void + { + $values = $this->fetchVersionAndNotUpsertableValues($this->class, $id); + + foreach ($values as $field => $value) { + $value = Type::getType($this->class->fieldMappings[$field]->type)->convertToPHPValue($value, $this->platform); + + $this->class->setFieldValue($entity, $field, $value); + } + } + + /** + * Fetches the current version value of a versioned entity and / or the values of fields + * marked as 'not insertable' and / or 'not updatable'. + * + * @param mixed[] $id + */ + protected function fetchVersionAndNotUpsertableValues(ClassMetadata $versionedClass, array $id): mixed + { + $columnNames = []; + foreach ($this->class->fieldMappings as $key => $column) { + if (isset($column->generated) || ($this->class->isVersioned && $key === $versionedClass->versionField)) { + $columnNames[$key] = $this->quoteStrategy->getColumnName($key, $versionedClass, $this->platform); + } + } + + $tableName = $this->quoteStrategy->getTableName($versionedClass, $this->platform); + $identifier = $this->quoteStrategy->getIdentifierColumnNames($versionedClass, $this->platform); + + // FIXME: Order with composite keys might not be correct + $sql = 'SELECT ' . implode(', ', $columnNames) + . ' FROM ' . $tableName + . ' WHERE ' . implode(' = ? AND ', $identifier) . ' = ?'; + + $flatId = $this->identifierFlattener->flattenIdentifier($versionedClass, $id); + + $values = $this->conn->fetchNumeric( + $sql, + array_values($flatId), + $this->extractIdentifierTypes($id, $versionedClass), + ); + + if ($values === false) { + throw new LengthException('Unexpected empty result for database query.'); + } + + $values = array_combine(array_keys($columnNames), $values); + + if (! $values) { + throw new LengthException('Unexpected number of database columns.'); + } + + return $values; + } + + /** + * @param mixed[] $id + * + * @return list + * @psalm-return list + */ + final protected function extractIdentifierTypes(array $id, ClassMetadata $versionedClass): array + { + $types = []; + + foreach ($id as $field => $value) { + $types = [...$types, ...$this->getTypes($field, $value, $versionedClass)]; + } + + return $types; + } + + public function update(object $entity): void + { + $tableName = $this->class->getTableName(); + $updateData = $this->prepareUpdateData($entity); + + if (! isset($updateData[$tableName])) { + return; + } + + $data = $updateData[$tableName]; + + if (! $data) { + return; + } + + $isVersioned = $this->class->isVersioned; + $quotedTableName = $this->quoteStrategy->getTableName($this->class, $this->platform); + + $this->updateTable($entity, $quotedTableName, $data, $isVersioned); + + if ($this->class->requiresFetchAfterChange) { + $id = $this->class->getIdentifierValues($entity); + + $this->assignDefaultVersionAndUpsertableValues($entity, $id); + } + } + + /** + * Performs an UPDATE statement for an entity on a specific table. + * The UPDATE can optionally be versioned, which requires the entity to have a version field. + * + * @param object $entity The entity object being updated. + * @param string $quotedTableName The quoted name of the table to apply the UPDATE on. + * @param mixed[] $updateData The map of columns to update (column => value). + * @param bool $versioned Whether the UPDATE should be versioned. + * + * @throws UnrecognizedField + * @throws OptimisticLockException + */ + final protected function updateTable( + object $entity, + string $quotedTableName, + array $updateData, + bool $versioned = false, + ): void { + $set = []; + $types = []; + $params = []; + + foreach ($updateData as $columnName => $value) { + $placeholder = '?'; + $column = $columnName; + + switch (true) { + case isset($this->class->fieldNames[$columnName]): + $fieldName = $this->class->fieldNames[$columnName]; + $column = $this->quoteStrategy->getColumnName($fieldName, $this->class, $this->platform); + + if (isset($this->class->fieldMappings[$fieldName])) { + $type = Type::getType($this->columnTypes[$columnName]); + $placeholder = $type->convertToDatabaseValueSQL('?', $this->platform); + } + + break; + + case isset($this->quotedColumns[$columnName]): + $column = $this->quotedColumns[$columnName]; + + break; + } + + $params[] = $value; + $set[] = $column . ' = ' . $placeholder; + $types[] = $this->columnTypes[$columnName]; + } + + $where = []; + $identifier = $this->em->getUnitOfWork()->getEntityIdentifier($entity); + + foreach ($this->class->identifier as $idField) { + if (! isset($this->class->associationMappings[$idField])) { + $params[] = $identifier[$idField]; + $types[] = $this->class->fieldMappings[$idField]->type; + $where[] = $this->quoteStrategy->getColumnName($idField, $this->class, $this->platform); + + continue; + } + + assert($this->class->associationMappings[$idField]->isToOneOwningSide()); + + $params[] = $identifier[$idField]; + $where[] = $this->quoteStrategy->getJoinColumnName( + $this->class->associationMappings[$idField]->joinColumns[0], + $this->class, + $this->platform, + ); + + $targetMapping = $this->em->getClassMetadata($this->class->associationMappings[$idField]->targetEntity); + $targetType = PersisterHelper::getTypeOfField($targetMapping->identifier[0], $targetMapping, $this->em); + + if ($targetType === []) { + throw UnrecognizedField::byFullyQualifiedName($this->class->name, $targetMapping->identifier[0]); + } + + $types[] = reset($targetType); + } + + if ($versioned) { + $versionField = $this->class->versionField; + assert($versionField !== null); + $versionFieldType = $this->class->fieldMappings[$versionField]->type; + $versionColumn = $this->quoteStrategy->getColumnName($versionField, $this->class, $this->platform); + + $where[] = $versionColumn; + $types[] = $this->class->fieldMappings[$versionField]->type; + $params[] = $this->class->reflFields[$versionField]->getValue($entity); + + switch ($versionFieldType) { + case Types::SMALLINT: + case Types::INTEGER: + case Types::BIGINT: + $set[] = $versionColumn . ' = ' . $versionColumn . ' + 1'; + break; + + case Types::DATETIME_MUTABLE: + $set[] = $versionColumn . ' = CURRENT_TIMESTAMP'; + break; + } + } + + $sql = 'UPDATE ' . $quotedTableName + . ' SET ' . implode(', ', $set) + . ' WHERE ' . implode(' = ? AND ', $where) . ' = ?'; + + $result = $this->conn->executeStatement($sql, $params, $types); + + if ($versioned && ! $result) { + throw OptimisticLockException::lockFailed($entity); + } + } + + /** + * @param array $identifier + * @param string[] $types + * + * @todo Add check for platform if it supports foreign keys/cascading. + */ + protected function deleteJoinTableRecords(array $identifier, array $types): void + { + foreach ($this->class->associationMappings as $mapping) { + if (! $mapping->isManyToMany() || $mapping->isOnDeleteCascade) { + continue; + } + + // @Todo this only covers scenarios with no inheritance or of the same level. Is there something + // like self-referential relationship between different levels of an inheritance hierarchy? I hope not! + $selfReferential = ($mapping->targetEntity === $mapping->sourceEntity); + $class = $this->class; + $association = $mapping; + $otherColumns = []; + $otherKeys = []; + $keys = []; + + if (! $mapping->isOwningSide()) { + $class = $this->em->getClassMetadata($mapping->targetEntity); + } + + $association = $this->em->getMetadataFactory()->getOwningSide($association); + $joinColumns = $mapping->isOwningSide() + ? $association->joinTable->joinColumns + : $association->joinTable->inverseJoinColumns; + + if ($selfReferential) { + $otherColumns = ! $mapping->isOwningSide() + ? $association->joinTable->joinColumns + : $association->joinTable->inverseJoinColumns; + } + + foreach ($joinColumns as $joinColumn) { + $keys[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $class, $this->platform); + } + + foreach ($otherColumns as $joinColumn) { + $otherKeys[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $class, $this->platform); + } + + $joinTableName = $this->quoteStrategy->getJoinTableName($association, $this->class, $this->platform); + + $this->conn->delete($joinTableName, array_combine($keys, $identifier), $types); + + if ($selfReferential) { + $this->conn->delete($joinTableName, array_combine($otherKeys, $identifier), $types); + } + } + } + + public function delete(object $entity): bool + { + $class = $this->class; + $identifier = $this->em->getUnitOfWork()->getEntityIdentifier($entity); + $tableName = $this->quoteStrategy->getTableName($class, $this->platform); + $idColumns = $this->quoteStrategy->getIdentifierColumnNames($class, $this->platform); + $id = array_combine($idColumns, $identifier); + $types = $this->getClassIdentifiersTypes($class); + + $this->deleteJoinTableRecords($identifier, $types); + + return (bool) $this->conn->delete($tableName, $id, $types); + } + + /** + * Prepares the changeset of an entity for database insertion (UPDATE). + * + * The changeset is obtained from the currently running UnitOfWork. + * + * During this preparation the array that is passed as the second parameter is filled with + * => pairs, grouped by table name. + * + * Example: + * + * array( + * 'foo_table' => array('column1' => 'value1', 'column2' => 'value2', ...), + * 'bar_table' => array('columnX' => 'valueX', 'columnY' => 'valueY', ...), + * ... + * ) + * + * + * @param object $entity The entity for which to prepare the data. + * @param bool $isInsert Whether the data to be prepared refers to an insert statement. + * + * @return mixed[][] The prepared data. + * @psalm-return array> + */ + protected function prepareUpdateData(object $entity, bool $isInsert = false): array + { + $versionField = null; + $result = []; + $uow = $this->em->getUnitOfWork(); + + $versioned = $this->class->isVersioned; + if ($versioned !== false) { + $versionField = $this->class->versionField; + } + + foreach ($uow->getEntityChangeSet($entity) as $field => $change) { + if (isset($versionField) && $versionField === $field) { + continue; + } + + if (isset($this->class->embeddedClasses[$field])) { + continue; + } + + $newVal = $change[1]; + + if (! isset($this->class->associationMappings[$field])) { + $fieldMapping = $this->class->fieldMappings[$field]; + $columnName = $fieldMapping->columnName; + + if (! $isInsert && isset($fieldMapping->notUpdatable)) { + continue; + } + + if ($isInsert && isset($fieldMapping->notInsertable)) { + continue; + } + + $this->columnTypes[$columnName] = $fieldMapping->type; + + $result[$this->getOwningTable($field)][$columnName] = $newVal; + + continue; + } + + $assoc = $this->class->associationMappings[$field]; + + // Only owning side of x-1 associations can have a FK column. + if (! $assoc->isToOneOwningSide()) { + continue; + } + + if ($newVal !== null) { + $oid = spl_object_id($newVal); + + // If the associated entity $newVal is not yet persisted and/or does not yet have + // an ID assigned, we must set $newVal = null. This will insert a null value and + // schedule an extra update on the UnitOfWork. + // + // This gives us extra time to a) possibly obtain a database-generated identifier + // value for $newVal, and b) insert $newVal into the database before the foreign + // key reference is being made. + // + // When looking at $this->queuedInserts and $uow->isScheduledForInsert, be aware + // of the implementation details that our own executeInserts() method will remove + // entities from the former as soon as the insert statement has been executed and + // a post-insert ID has been assigned (if necessary), and that the UnitOfWork has + // already removed entities from its own list at the time they were passed to our + // addInsert() method. + // + // Then, there is one extra exception we can make: An entity that references back to itself + // _and_ uses an application-provided ID (the "NONE" generator strategy) also does not + // need the extra update, although it is still in the list of insertions itself. + // This looks like a minor optimization at first, but is the capstone for being able to + // use non-NULLable, self-referencing associations in applications that provide IDs (like UUIDs). + if ( + (isset($this->queuedInserts[$oid]) || $uow->isScheduledForInsert($newVal)) + && ! ($newVal === $entity && $this->class->isIdentifierNatural()) + ) { + $uow->scheduleExtraUpdate($entity, [$field => [null, $newVal]]); + + $newVal = null; + } + } + + $newValId = null; + + if ($newVal !== null) { + $newValId = $uow->getEntityIdentifier($newVal); + } + + $targetClass = $this->em->getClassMetadata($assoc->targetEntity); + $owningTable = $this->getOwningTable($field); + + foreach ($assoc->joinColumns as $joinColumn) { + $sourceColumn = $joinColumn->name; + $targetColumn = $joinColumn->referencedColumnName; + $quotedColumn = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); + + $this->quotedColumns[$sourceColumn] = $quotedColumn; + $this->columnTypes[$sourceColumn] = PersisterHelper::getTypeOfColumn($targetColumn, $targetClass, $this->em); + $result[$owningTable][$sourceColumn] = $newValId + ? $newValId[$targetClass->getFieldForColumn($targetColumn)] + : null; + } + } + + return $result; + } + + /** + * Prepares the data changeset of a managed entity for database insertion (initial INSERT). + * The changeset of the entity is obtained from the currently running UnitOfWork. + * + * The default insert data preparation is the same as for updates. + * + * @see prepareUpdateData + * + * @param object $entity The entity for which to prepare the data. + * + * @return mixed[][] The prepared data for the tables to update. + * @psalm-return array + */ + protected function prepareInsertData(object $entity): array + { + return $this->prepareUpdateData($entity, true); + } + + public function getOwningTable(string $fieldName): string + { + return $this->class->getTableName(); + } + + /** + * {@inheritDoc} + */ + public function load( + array $criteria, + object|null $entity = null, + AssociationMapping|null $assoc = null, + array $hints = [], + LockMode|int|null $lockMode = null, + int|null $limit = null, + array|null $orderBy = null, + ): object|null { + $this->switchPersisterContext(null, $limit); + + $sql = $this->getSelectSQL($criteria, $assoc, $lockMode, $limit, null, $orderBy); + [$params, $types] = $this->expandParameters($criteria); + $stmt = $this->conn->executeQuery($sql, $params, $types); + + if ($entity !== null) { + $hints[Query::HINT_REFRESH] = true; + $hints[Query::HINT_REFRESH_ENTITY] = $entity; + } + + $hydrator = $this->em->newHydrator($this->currentPersisterContext->selectJoinSql ? Query::HYDRATE_OBJECT : Query::HYDRATE_SIMPLEOBJECT); + $entities = $hydrator->hydrateAll($stmt, $this->currentPersisterContext->rsm, $hints); + + return $entities ? $entities[0] : null; + } + + /** + * {@inheritDoc} + */ + public function loadById(array $identifier, object|null $entity = null): object|null + { + return $this->load($identifier, $entity); + } + + /** + * {@inheritDoc} + */ + public function loadOneToOneEntity(AssociationMapping $assoc, object $sourceEntity, array $identifier = []): object|null + { + $foundEntity = $this->em->getUnitOfWork()->tryGetById($identifier, $assoc->targetEntity); + if ($foundEntity !== false) { + return $foundEntity; + } + + $targetClass = $this->em->getClassMetadata($assoc->targetEntity); + + if ($assoc->isOwningSide()) { + $isInverseSingleValued = $assoc->inversedBy !== null && ! $targetClass->isCollectionValuedAssociation($assoc->inversedBy); + + // Mark inverse side as fetched in the hints, otherwise the UoW would + // try to load it in a separate query (remember: to-one inverse sides can not be lazy). + $hints = []; + + if ($isInverseSingleValued) { + $hints['fetched']['r'][$assoc->inversedBy] = true; + } + + $targetEntity = $this->load($identifier, null, $assoc, $hints); + + // Complete bidirectional association, if necessary + if ($targetEntity !== null && $isInverseSingleValued) { + $targetClass->reflFields[$assoc->inversedBy]->setValue($targetEntity, $sourceEntity); + } + + return $targetEntity; + } + + assert(isset($assoc->mappedBy)); + $sourceClass = $this->em->getClassMetadata($assoc->sourceEntity); + $owningAssoc = $targetClass->getAssociationMapping($assoc->mappedBy); + assert($owningAssoc->isOneToOneOwningSide()); + + $computedIdentifier = []; + + // TRICKY: since the association is specular source and target are flipped + foreach ($owningAssoc->targetToSourceKeyColumns as $sourceKeyColumn => $targetKeyColumn) { + if (! isset($sourceClass->fieldNames[$sourceKeyColumn])) { + throw MappingException::joinColumnMustPointToMappedField( + $sourceClass->name, + $sourceKeyColumn, + ); + } + + $computedIdentifier[$targetClass->getFieldForColumn($targetKeyColumn)] = + $sourceClass->reflFields[$sourceClass->fieldNames[$sourceKeyColumn]]->getValue($sourceEntity); + } + + $targetEntity = $this->load($computedIdentifier, null, $assoc); + + if ($targetEntity !== null) { + $targetClass->setFieldValue($targetEntity, $assoc->mappedBy, $sourceEntity); + } + + return $targetEntity; + } + + /** + * {@inheritDoc} + */ + public function refresh(array $id, object $entity, LockMode|int|null $lockMode = null): void + { + $sql = $this->getSelectSQL($id, null, $lockMode); + [$params, $types] = $this->expandParameters($id); + $stmt = $this->conn->executeQuery($sql, $params, $types); + + $hydrator = $this->em->newHydrator(Query::HYDRATE_OBJECT); + $hydrator->hydrateAll($stmt, $this->currentPersisterContext->rsm, [Query::HINT_REFRESH => true]); + } + + public function count(array|Criteria $criteria = []): int + { + $sql = $this->getCountSQL($criteria); + + [$params, $types] = $criteria instanceof Criteria + ? $this->expandCriteriaParameters($criteria) + : $this->expandParameters($criteria); + + return (int) $this->conn->executeQuery($sql, $params, $types)->fetchOne(); + } + + /** + * {@inheritDoc} + */ + public function loadCriteria(Criteria $criteria): array + { + $orderBy = array_map( + static fn (Order $order): string => $order->value, + $criteria->orderings(), + ); + $limit = $criteria->getMaxResults(); + $offset = $criteria->getFirstResult(); + $query = $this->getSelectSQL($criteria, null, null, $limit, $offset, $orderBy); + + [$params, $types] = $this->expandCriteriaParameters($criteria); + + $stmt = $this->conn->executeQuery($query, $params, $types); + $hydrator = $this->em->newHydrator($this->currentPersisterContext->selectJoinSql ? Query::HYDRATE_OBJECT : Query::HYDRATE_SIMPLEOBJECT); + + return $hydrator->hydrateAll($stmt, $this->currentPersisterContext->rsm, [UnitOfWork::HINT_DEFEREAGERLOAD => true]); + } + + /** + * {@inheritDoc} + */ + public function expandCriteriaParameters(Criteria $criteria): array + { + $expression = $criteria->getWhereExpression(); + $sqlParams = []; + $sqlTypes = []; + + if ($expression === null) { + return [$sqlParams, $sqlTypes]; + } + + $valueVisitor = new SqlValueVisitor(); + + $valueVisitor->dispatch($expression); + + [, $types] = $valueVisitor->getParamsAndTypes(); + + foreach ($types as $type) { + [$field, $value, $operator] = $type; + + if ($value === null && ($operator === Comparison::EQ || $operator === Comparison::NEQ)) { + continue; + } + + $sqlParams = [...$sqlParams, ...$this->getValues($value)]; + $sqlTypes = [...$sqlTypes, ...$this->getTypes($field, $value, $this->class)]; + } + + return [$sqlParams, $sqlTypes]; + } + + /** + * {@inheritDoc} + */ + public function loadAll( + array $criteria = [], + array|null $orderBy = null, + int|null $limit = null, + int|null $offset = null, + ): array { + $this->switchPersisterContext($offset, $limit); + + $sql = $this->getSelectSQL($criteria, null, null, $limit, $offset, $orderBy); + [$params, $types] = $this->expandParameters($criteria); + $stmt = $this->conn->executeQuery($sql, $params, $types); + + $hydrator = $this->em->newHydrator($this->currentPersisterContext->selectJoinSql ? Query::HYDRATE_OBJECT : Query::HYDRATE_SIMPLEOBJECT); + + return $hydrator->hydrateAll($stmt, $this->currentPersisterContext->rsm, [UnitOfWork::HINT_DEFEREAGERLOAD => true]); + } + + /** + * {@inheritDoc} + */ + public function getManyToManyCollection( + AssociationMapping $assoc, + object $sourceEntity, + int|null $offset = null, + int|null $limit = null, + ): array { + assert($assoc->isManyToMany()); + $this->switchPersisterContext($offset, $limit); + + $stmt = $this->getManyToManyStatement($assoc, $sourceEntity, $offset, $limit); + + return $this->loadArrayFromResult($assoc, $stmt); + } + + /** + * Loads an array of entities from a given DBAL statement. + * + * @return mixed[] + */ + private function loadArrayFromResult(AssociationMapping $assoc, Result $stmt): array + { + $rsm = $this->currentPersisterContext->rsm; + $hints = [UnitOfWork::HINT_DEFEREAGERLOAD => true]; + + if ($assoc->isIndexed()) { + $rsm = clone $this->currentPersisterContext->rsm; // this is necessary because the "default rsm" should be changed. + $rsm->addIndexBy('r', $assoc->indexBy()); + } + + return $this->em->newHydrator(Query::HYDRATE_OBJECT)->hydrateAll($stmt, $rsm, $hints); + } + + /** + * Hydrates a collection from a given DBAL statement. + * + * @return mixed[] + */ + private function loadCollectionFromStatement( + AssociationMapping $assoc, + Result $stmt, + PersistentCollection $coll, + ): array { + $rsm = $this->currentPersisterContext->rsm; + $hints = [ + UnitOfWork::HINT_DEFEREAGERLOAD => true, + 'collection' => $coll, + ]; + + if ($assoc->isIndexed()) { + $rsm = clone $this->currentPersisterContext->rsm; // this is necessary because the "default rsm" should be changed. + $rsm->addIndexBy('r', $assoc->indexBy()); + } + + return $this->em->newHydrator(Query::HYDRATE_OBJECT)->hydrateAll($stmt, $rsm, $hints); + } + + /** + * {@inheritDoc} + */ + public function loadManyToManyCollection(AssociationMapping $assoc, object $sourceEntity, PersistentCollection $collection): array + { + assert($assoc->isManyToMany()); + $stmt = $this->getManyToManyStatement($assoc, $sourceEntity); + + return $this->loadCollectionFromStatement($assoc, $stmt, $collection); + } + + /** @throws MappingException */ + private function getManyToManyStatement( + AssociationMapping&ManyToManyAssociationMapping $assoc, + object $sourceEntity, + int|null $offset = null, + int|null $limit = null, + ): Result { + $this->switchPersisterContext($offset, $limit); + + $sourceClass = $this->em->getClassMetadata($assoc->sourceEntity); + $class = $sourceClass; + $association = $assoc; + $criteria = []; + $parameters = []; + + if (! $assoc->isOwningSide()) { + $class = $this->em->getClassMetadata($assoc->targetEntity); + } + + $association = $this->em->getMetadataFactory()->getOwningSide($assoc); + $joinColumns = $assoc->isOwningSide() + ? $association->joinTable->joinColumns + : $association->joinTable->inverseJoinColumns; + + $quotedJoinTable = $this->quoteStrategy->getJoinTableName($association, $class, $this->platform); + + foreach ($joinColumns as $joinColumn) { + $sourceKeyColumn = $joinColumn->referencedColumnName; + $quotedKeyColumn = $this->quoteStrategy->getJoinColumnName($joinColumn, $class, $this->platform); + + switch (true) { + case $sourceClass->containsForeignIdentifier: + $field = $sourceClass->getFieldForColumn($sourceKeyColumn); + $value = $sourceClass->reflFields[$field]->getValue($sourceEntity); + + if (isset($sourceClass->associationMappings[$field])) { + $value = $this->em->getUnitOfWork()->getEntityIdentifier($value); + $value = $value[$this->em->getClassMetadata($sourceClass->associationMappings[$field]->targetEntity)->identifier[0]]; + } + + break; + + case isset($sourceClass->fieldNames[$sourceKeyColumn]): + $field = $sourceClass->fieldNames[$sourceKeyColumn]; + $value = $sourceClass->reflFields[$field]->getValue($sourceEntity); + + break; + + default: + throw MappingException::joinColumnMustPointToMappedField( + $sourceClass->name, + $sourceKeyColumn, + ); + } + + $criteria[$quotedJoinTable . '.' . $quotedKeyColumn] = $value; + $parameters[] = [ + 'value' => $value, + 'field' => $field, + 'class' => $sourceClass, + ]; + } + + $sql = $this->getSelectSQL($criteria, $assoc, null, $limit, $offset); + [$params, $types] = $this->expandToManyParameters($parameters); + + return $this->conn->executeQuery($sql, $params, $types); + } + + public function getSelectSQL( + array|Criteria $criteria, + AssociationMapping|null $assoc = null, + LockMode|int|null $lockMode = null, + int|null $limit = null, + int|null $offset = null, + array|null $orderBy = null, + ): string { + $this->switchPersisterContext($offset, $limit); + + $joinSql = ''; + $orderBySql = ''; + + if ($assoc !== null && $assoc->isManyToMany()) { + $joinSql = $this->getSelectManyToManyJoinSQL($assoc); + } + + if ($assoc !== null && $assoc->isOrdered()) { + $orderBy = $assoc->orderBy(); + } + + if ($orderBy) { + $orderBySql = $this->getOrderBySQL($orderBy, $this->getSQLTableAlias($this->class->name)); + } + + $conditionSql = $criteria instanceof Criteria + ? $this->getSelectConditionCriteriaSQL($criteria) + : $this->getSelectConditionSQL($criteria, $assoc); + + $lockSql = match ($lockMode) { + LockMode::PESSIMISTIC_READ => ' ' . $this->getReadLockSQL($this->platform), + LockMode::PESSIMISTIC_WRITE => ' ' . $this->getWriteLockSQL($this->platform), + default => '', + }; + + $columnList = $this->getSelectColumnsSQL(); + $tableAlias = $this->getSQLTableAlias($this->class->name); + $filterSql = $this->generateFilterConditionSQL($this->class, $tableAlias); + $tableName = $this->quoteStrategy->getTableName($this->class, $this->platform); + + if ($filterSql !== '') { + $conditionSql = $conditionSql + ? $conditionSql . ' AND ' . $filterSql + : $filterSql; + } + + $select = 'SELECT ' . $columnList; + $from = ' FROM ' . $tableName . ' ' . $tableAlias; + $join = $this->currentPersisterContext->selectJoinSql . $joinSql; + $where = ($conditionSql ? ' WHERE ' . $conditionSql : ''); + $lock = $this->platform->appendLockHint($from, $lockMode ?? LockMode::NONE); + $query = $select + . $lock + . $join + . $where + . $orderBySql; + + return $this->platform->modifyLimitQuery($query, $limit, $offset ?? 0) . $lockSql; + } + + public function getCountSQL(array|Criteria $criteria = []): string + { + $tableName = $this->quoteStrategy->getTableName($this->class, $this->platform); + $tableAlias = $this->getSQLTableAlias($this->class->name); + + $conditionSql = $criteria instanceof Criteria + ? $this->getSelectConditionCriteriaSQL($criteria) + : $this->getSelectConditionSQL($criteria); + + $filterSql = $this->generateFilterConditionSQL($this->class, $tableAlias); + + if ($filterSql !== '') { + $conditionSql = $conditionSql + ? $conditionSql . ' AND ' . $filterSql + : $filterSql; + } + + return 'SELECT COUNT(*) ' + . 'FROM ' . $tableName . ' ' . $tableAlias + . (empty($conditionSql) ? '' : ' WHERE ' . $conditionSql); + } + + /** + * Gets the ORDER BY SQL snippet for ordered collections. + * + * @psalm-param array $orderBy + * + * @throws InvalidOrientation + * @throws InvalidFindByCall + * @throws UnrecognizedField + */ + final protected function getOrderBySQL(array $orderBy, string $baseTableAlias): string + { + $orderByList = []; + + foreach ($orderBy as $fieldName => $orientation) { + $orientation = strtoupper(trim($orientation)); + + if ($orientation !== 'ASC' && $orientation !== 'DESC') { + throw InvalidOrientation::fromClassNameAndField($this->class->name, $fieldName); + } + + if (isset($this->class->fieldMappings[$fieldName])) { + $tableAlias = isset($this->class->fieldMappings[$fieldName]->inherited) + ? $this->getSQLTableAlias($this->class->fieldMappings[$fieldName]->inherited) + : $baseTableAlias; + + $columnName = $this->quoteStrategy->getColumnName($fieldName, $this->class, $this->platform); + $orderByList[] = $tableAlias . '.' . $columnName . ' ' . $orientation; + + continue; + } + + if (isset($this->class->associationMappings[$fieldName])) { + $association = $this->class->associationMappings[$fieldName]; + if (! $association->isOwningSide()) { + throw InvalidFindByCall::fromInverseSideUsage($this->class->name, $fieldName); + } + + assert($association->isToOneOwningSide()); + + $tableAlias = isset($association->inherited) + ? $this->getSQLTableAlias($association->inherited) + : $baseTableAlias; + + foreach ($association->joinColumns as $joinColumn) { + $columnName = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); + $orderByList[] = $tableAlias . '.' . $columnName . ' ' . $orientation; + } + + continue; + } + + throw UnrecognizedField::byFullyQualifiedName($this->class->name, $fieldName); + } + + return ' ORDER BY ' . implode(', ', $orderByList); + } + + /** + * Gets the SQL fragment with the list of columns to select when querying for + * an entity in this persister. + * + * Subclasses should override this method to alter or change the select column + * list SQL fragment. Note that in the implementation of BasicEntityPersister + * the resulting SQL fragment is generated only once and cached in {@link selectColumnListSql}. + * Subclasses may or may not do the same. + */ + protected function getSelectColumnsSQL(): string + { + if ($this->currentPersisterContext->selectColumnListSql !== null) { + return $this->currentPersisterContext->selectColumnListSql; + } + + $columnList = []; + $this->currentPersisterContext->rsm->addEntityResult($this->class->name, 'r'); // r for root + + // Add regular columns to select list + foreach ($this->class->fieldNames as $field) { + $columnList[] = $this->getSelectColumnSQL($field, $this->class); + } + + $this->currentPersisterContext->selectJoinSql = ''; + $eagerAliasCounter = 0; + + foreach ($this->class->associationMappings as $assocField => $assoc) { + $assocColumnSQL = $this->getSelectColumnAssociationSQL($assocField, $assoc, $this->class); + + if ($assocColumnSQL) { + $columnList[] = $assocColumnSQL; + } + + $isAssocToOneInverseSide = $assoc->isToOne() && ! $assoc->isOwningSide(); + $isAssocFromOneEager = $assoc->isToOne() && $assoc->fetch === ClassMetadata::FETCH_EAGER; + + if (! ($isAssocFromOneEager || $isAssocToOneInverseSide)) { + continue; + } + + if ($assoc->isToMany() && $this->currentPersisterContext->handlesLimits) { + continue; + } + + $eagerEntity = $this->em->getClassMetadata($assoc->targetEntity); + + if ($eagerEntity->inheritanceType !== ClassMetadata::INHERITANCE_TYPE_NONE) { + continue; // now this is why you shouldn't use inheritance + } + + $assocAlias = 'e' . ($eagerAliasCounter++); + $this->currentPersisterContext->rsm->addJoinedEntityResult($assoc->targetEntity, $assocAlias, 'r', $assocField); + + foreach ($eagerEntity->fieldNames as $field) { + $columnList[] = $this->getSelectColumnSQL($field, $eagerEntity, $assocAlias); + } + + foreach ($eagerEntity->associationMappings as $eagerAssocField => $eagerAssoc) { + $eagerAssocColumnSQL = $this->getSelectColumnAssociationSQL( + $eagerAssocField, + $eagerAssoc, + $eagerEntity, + $assocAlias, + ); + + if ($eagerAssocColumnSQL) { + $columnList[] = $eagerAssocColumnSQL; + } + } + + $association = $assoc; + $joinCondition = []; + + if ($assoc->isIndexed()) { + assert($assoc->isToMany()); + $this->currentPersisterContext->rsm->addIndexBy($assocAlias, $assoc->indexBy()); + } + + if (! $assoc->isOwningSide()) { + $eagerEntity = $this->em->getClassMetadata($assoc->targetEntity); + $association = $eagerEntity->getAssociationMapping($assoc->mappedBy); + } + + assert($association->isToOneOwningSide()); + + $joinTableAlias = $this->getSQLTableAlias($eagerEntity->name, $assocAlias); + $joinTableName = $this->quoteStrategy->getTableName($eagerEntity, $this->platform); + + if ($assoc->isOwningSide()) { + $tableAlias = $this->getSQLTableAlias($association->targetEntity, $assocAlias); + $this->currentPersisterContext->selectJoinSql .= ' ' . $this->getJoinSQLForJoinColumns($association->joinColumns); + + foreach ($association->joinColumns as $joinColumn) { + $sourceCol = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); + $targetCol = $this->quoteStrategy->getReferencedJoinColumnName($joinColumn, $this->class, $this->platform); + $joinCondition[] = $this->getSQLTableAlias($association->sourceEntity) + . '.' . $sourceCol . ' = ' . $tableAlias . '.' . $targetCol; + } + + // Add filter SQL + $filterSql = $this->generateFilterConditionSQL($eagerEntity, $tableAlias); + if ($filterSql) { + $joinCondition[] = $filterSql; + } + } else { + $this->currentPersisterContext->selectJoinSql .= ' LEFT JOIN'; + + foreach ($association->joinColumns as $joinColumn) { + $sourceCol = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); + $targetCol = $this->quoteStrategy->getReferencedJoinColumnName($joinColumn, $this->class, $this->platform); + + $joinCondition[] = $this->getSQLTableAlias($association->sourceEntity, $assocAlias) . '.' . $sourceCol . ' = ' + . $this->getSQLTableAlias($association->targetEntity) . '.' . $targetCol; + } + } + + $this->currentPersisterContext->selectJoinSql .= ' ' . $joinTableName . ' ' . $joinTableAlias . ' ON '; + $this->currentPersisterContext->selectJoinSql .= implode(' AND ', $joinCondition); + } + + $this->currentPersisterContext->selectColumnListSql = implode(', ', $columnList); + + return $this->currentPersisterContext->selectColumnListSql; + } + + /** Gets the SQL join fragment used when selecting entities from an association. */ + protected function getSelectColumnAssociationSQL( + string $field, + AssociationMapping $assoc, + ClassMetadata $class, + string $alias = 'r', + ): string { + if (! $assoc->isToOneOwningSide()) { + return ''; + } + + $columnList = []; + $targetClass = $this->em->getClassMetadata($assoc->targetEntity); + $isIdentifier = isset($assoc->id) && $assoc->id === true; + $sqlTableAlias = $this->getSQLTableAlias($class->name, ($alias === 'r' ? '' : $alias)); + + foreach ($assoc->joinColumns as $joinColumn) { + $quotedColumn = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); + $resultColumnName = $this->getSQLColumnAlias($joinColumn->name); + $type = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $targetClass, $this->em); + + $this->currentPersisterContext->rsm->addMetaResult($alias, $resultColumnName, $joinColumn->name, $isIdentifier, $type); + + $columnList[] = sprintf('%s.%s AS %s', $sqlTableAlias, $quotedColumn, $resultColumnName); + } + + return implode(', ', $columnList); + } + + /** + * Gets the SQL join fragment used when selecting entities from a + * many-to-many association. + */ + protected function getSelectManyToManyJoinSQL(AssociationMapping&ManyToManyAssociationMapping $manyToMany): string + { + $conditions = []; + $association = $manyToMany; + $sourceTableAlias = $this->getSQLTableAlias($this->class->name); + + $association = $this->em->getMetadataFactory()->getOwningSide($manyToMany); + $joinTableName = $this->quoteStrategy->getJoinTableName($association, $this->class, $this->platform); + $joinColumns = $manyToMany->isOwningSide() + ? $association->joinTable->inverseJoinColumns + : $association->joinTable->joinColumns; + + foreach ($joinColumns as $joinColumn) { + $quotedSourceColumn = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); + $quotedTargetColumn = $this->quoteStrategy->getReferencedJoinColumnName($joinColumn, $this->class, $this->platform); + $conditions[] = $sourceTableAlias . '.' . $quotedTargetColumn . ' = ' . $joinTableName . '.' . $quotedSourceColumn; + } + + return ' INNER JOIN ' . $joinTableName . ' ON ' . implode(' AND ', $conditions); + } + + public function getInsertSQL(): string + { + if ($this->insertSql !== null) { + return $this->insertSql; + } + + $columns = $this->getInsertColumnList(); + $tableName = $this->quoteStrategy->getTableName($this->class, $this->platform); + + if (empty($columns)) { + $identityColumn = $this->quoteStrategy->getColumnName($this->class->identifier[0], $this->class, $this->platform); + $this->insertSql = $this->platform->getEmptyIdentityInsertSQL($tableName, $identityColumn); + + return $this->insertSql; + } + + $values = []; + $columns = array_unique($columns); + + foreach ($columns as $column) { + $placeholder = '?'; + + if ( + isset($this->class->fieldNames[$column]) + && isset($this->columnTypes[$this->class->fieldNames[$column]]) + && isset($this->class->fieldMappings[$this->class->fieldNames[$column]]) + ) { + $type = Type::getType($this->columnTypes[$this->class->fieldNames[$column]]); + $placeholder = $type->convertToDatabaseValueSQL('?', $this->platform); + } + + $values[] = $placeholder; + } + + $columns = implode(', ', $columns); + $values = implode(', ', $values); + + $this->insertSql = sprintf('INSERT INTO %s (%s) VALUES (%s)', $tableName, $columns, $values); + + return $this->insertSql; + } + + /** + * Gets the list of columns to put in the INSERT SQL statement. + * + * Subclasses should override this method to alter or change the list of + * columns placed in the INSERT statements used by the persister. + * + * @psalm-return list + */ + protected function getInsertColumnList(): array + { + $columns = []; + + foreach ($this->class->reflFields as $name => $field) { + if ($this->class->isVersioned && $this->class->versionField === $name) { + continue; + } + + if (isset($this->class->embeddedClasses[$name])) { + continue; + } + + if (isset($this->class->associationMappings[$name])) { + $assoc = $this->class->associationMappings[$name]; + + if ($assoc->isToOneOwningSide()) { + foreach ($assoc->joinColumns as $joinColumn) { + $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); + } + } + + continue; + } + + if (! $this->class->isIdGeneratorIdentity() || $this->class->identifier[0] !== $name) { + if (isset($this->class->fieldMappings[$name]->notInsertable)) { + continue; + } + + $columns[] = $this->quoteStrategy->getColumnName($name, $this->class, $this->platform); + $this->columnTypes[$name] = $this->class->fieldMappings[$name]->type; + } + } + + return $columns; + } + + /** + * Gets the SQL snippet of a qualified column name for the given field name. + * + * @param ClassMetadata $class The class that declares this field. The table this class is + * mapped to must own the column for the given field. + */ + protected function getSelectColumnSQL(string $field, ClassMetadata $class, string $alias = 'r'): string + { + $root = $alias === 'r' ? '' : $alias; + $tableAlias = $this->getSQLTableAlias($class->name, $root); + $fieldMapping = $class->fieldMappings[$field]; + $sql = sprintf('%s.%s', $tableAlias, $this->quoteStrategy->getColumnName($field, $class, $this->platform)); + $columnAlias = $this->getSQLColumnAlias($fieldMapping->columnName); + + $this->currentPersisterContext->rsm->addFieldResult($alias, $columnAlias, $field); + if (! empty($fieldMapping->enumType)) { + $this->currentPersisterContext->rsm->addEnumResult($columnAlias, $fieldMapping->enumType); + } + + $type = Type::getType($fieldMapping->type); + $sql = $type->convertToPHPValueSQL($sql, $this->platform); + + return $sql . ' AS ' . $columnAlias; + } + + /** + * Gets the SQL table alias for the given class name. + * + * @todo Reconsider. Binding table aliases to class names is not such a good idea. + */ + protected function getSQLTableAlias(string $className, string $assocName = ''): string + { + if ($assocName) { + $className .= '#' . $assocName; + } + + if (isset($this->currentPersisterContext->sqlTableAliases[$className])) { + return $this->currentPersisterContext->sqlTableAliases[$className]; + } + + $tableAlias = 't' . $this->currentPersisterContext->sqlAliasCounter++; + + $this->currentPersisterContext->sqlTableAliases[$className] = $tableAlias; + + return $tableAlias; + } + + /** + * {@inheritDoc} + */ + public function lock(array $criteria, LockMode|int $lockMode): void + { + $conditionSql = $this->getSelectConditionSQL($criteria); + + $lockSql = match ($lockMode) { + LockMode::PESSIMISTIC_READ => $this->getReadLockSQL($this->platform), + LockMode::PESSIMISTIC_WRITE => $this->getWriteLockSQL($this->platform), + default => '', + }; + + $lock = $this->getLockTablesSql($lockMode); + $where = ($conditionSql ? ' WHERE ' . $conditionSql : '') . ' '; + $sql = 'SELECT 1 ' + . $lock + . $where + . $lockSql; + + [$params, $types] = $this->expandParameters($criteria); + + $this->conn->executeQuery($sql, $params, $types); + } + + /** + * Gets the FROM and optionally JOIN conditions to lock the entity managed by this persister. + * + * @psalm-param LockMode::* $lockMode + */ + protected function getLockTablesSql(LockMode|int $lockMode): string + { + return $this->platform->appendLockHint( + 'FROM ' + . $this->quoteStrategy->getTableName($this->class, $this->platform) . ' ' + . $this->getSQLTableAlias($this->class->name), + $lockMode, + ); + } + + /** + * Gets the Select Where Condition from a Criteria object. + */ + protected function getSelectConditionCriteriaSQL(Criteria $criteria): string + { + $expression = $criteria->getWhereExpression(); + + if ($expression === null) { + return ''; + } + + $visitor = new SqlExpressionVisitor($this, $this->class); + + return $visitor->dispatch($expression); + } + + public function getSelectConditionStatementSQL( + string $field, + mixed $value, + AssociationMapping|null $assoc = null, + string|null $comparison = null, + ): string { + $selectedColumns = []; + $columns = $this->getSelectConditionStatementColumnSQL($field, $assoc); + + if (count($columns) > 1 && $comparison === Comparison::IN) { + /* + * @todo try to support multi-column IN expressions. + * Example: (col1, col2) IN (('val1A', 'val2A'), ('val1B', 'val2B')) + */ + throw CantUseInOperatorOnCompositeKeys::create(); + } + + foreach ($columns as $column) { + $placeholder = '?'; + + if (isset($this->class->fieldMappings[$field])) { + $type = Type::getType($this->class->fieldMappings[$field]->type); + $placeholder = $type->convertToDatabaseValueSQL($placeholder, $this->platform); + } + + if ($comparison !== null) { + // special case null value handling + if (($comparison === Comparison::EQ || $comparison === Comparison::IS) && $value === null) { + $selectedColumns[] = $column . ' IS NULL'; + + continue; + } + + if ($comparison === Comparison::NEQ && $value === null) { + $selectedColumns[] = $column . ' IS NOT NULL'; + + continue; + } + + $selectedColumns[] = $column . ' ' . sprintf(self::$comparisonMap[$comparison], $placeholder); + + continue; + } + + if (is_array($value)) { + $in = sprintf('%s IN (%s)', $column, $placeholder); + + if (array_search(null, $value, true) !== false) { + $selectedColumns[] = sprintf('(%s OR %s IS NULL)', $in, $column); + + continue; + } + + $selectedColumns[] = $in; + + continue; + } + + if ($value === null) { + $selectedColumns[] = sprintf('%s IS NULL', $column); + + continue; + } + + $selectedColumns[] = sprintf('%s = %s', $column, $placeholder); + } + + return implode(' AND ', $selectedColumns); + } + + /** + * Builds the left-hand-side of a where condition statement. + * + * @return string[] + * @psalm-return list + * + * @throws InvalidFindByCall + * @throws UnrecognizedField + */ + private function getSelectConditionStatementColumnSQL( + string $field, + AssociationMapping|null $assoc = null, + ): array { + if (isset($this->class->fieldMappings[$field])) { + $className = $this->class->fieldMappings[$field]->inherited ?? $this->class->name; + + return [$this->getSQLTableAlias($className) . '.' . $this->quoteStrategy->getColumnName($field, $this->class, $this->platform)]; + } + + if (isset($this->class->associationMappings[$field])) { + $association = $this->class->associationMappings[$field]; + // Many-To-Many requires join table check for joinColumn + $columns = []; + $class = $this->class; + + if ($association->isManyToMany()) { + assert($assoc !== null); + if (! $association->isOwningSide()) { + $association = $assoc; + } + + assert($association->isManyToManyOwningSide()); + + $joinTableName = $this->quoteStrategy->getJoinTableName($association, $class, $this->platform); + $joinColumns = $assoc->isOwningSide() + ? $association->joinTable->joinColumns + : $association->joinTable->inverseJoinColumns; + + foreach ($joinColumns as $joinColumn) { + $columns[] = $joinTableName . '.' . $this->quoteStrategy->getJoinColumnName($joinColumn, $class, $this->platform); + } + } else { + if (! $association->isOwningSide()) { + throw InvalidFindByCall::fromInverseSideUsage( + $this->class->name, + $field, + ); + } + + assert($association->isToOneOwningSide()); + + $className = $association->inherited ?? $this->class->name; + + foreach ($association->joinColumns as $joinColumn) { + $columns[] = $this->getSQLTableAlias($className) . '.' . $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); + } + } + + return $columns; + } + + if ($assoc !== null && ! str_contains($field, ' ') && ! str_contains($field, '(')) { + // very careless developers could potentially open up this normally hidden api for userland attacks, + // therefore checking for spaces and function calls which are not allowed. + + // found a join column condition, not really a "field" + return [$field]; + } + + throw UnrecognizedField::byFullyQualifiedName($this->class->name, $field); + } + + /** + * Gets the conditional SQL fragment used in the WHERE clause when selecting + * entities in this persister. + * + * Subclasses are supposed to override this method if they intend to change + * or alter the criteria by which entities are selected. + * + * @psalm-param array $criteria + */ + protected function getSelectConditionSQL(array $criteria, AssociationMapping|null $assoc = null): string + { + $conditions = []; + + foreach ($criteria as $field => $value) { + $conditions[] = $this->getSelectConditionStatementSQL($field, $value, $assoc); + } + + return implode(' AND ', $conditions); + } + + /** + * {@inheritDoc} + */ + public function getOneToManyCollection( + AssociationMapping $assoc, + object $sourceEntity, + int|null $offset = null, + int|null $limit = null, + ): array { + assert($assoc instanceof OneToManyAssociationMapping); + $this->switchPersisterContext($offset, $limit); + + $stmt = $this->getOneToManyStatement($assoc, $sourceEntity, $offset, $limit); + + return $this->loadArrayFromResult($assoc, $stmt); + } + + public function loadOneToManyCollection( + AssociationMapping $assoc, + object $sourceEntity, + PersistentCollection $collection, + ): mixed { + assert($assoc instanceof OneToManyAssociationMapping); + $stmt = $this->getOneToManyStatement($assoc, $sourceEntity); + + return $this->loadCollectionFromStatement($assoc, $stmt, $collection); + } + + /** Builds criteria and execute SQL statement to fetch the one to many entities from. */ + private function getOneToManyStatement( + OneToManyAssociationMapping $assoc, + object $sourceEntity, + int|null $offset = null, + int|null $limit = null, + ): Result { + $this->switchPersisterContext($offset, $limit); + + $criteria = []; + $parameters = []; + $owningAssoc = $this->class->associationMappings[$assoc->mappedBy]; + $sourceClass = $this->em->getClassMetadata($assoc->sourceEntity); + $tableAlias = $this->getSQLTableAlias($owningAssoc->inherited ?? $this->class->name); + assert($owningAssoc->isManyToOne()); + + foreach ($owningAssoc->targetToSourceKeyColumns as $sourceKeyColumn => $targetKeyColumn) { + if ($sourceClass->containsForeignIdentifier) { + $field = $sourceClass->getFieldForColumn($sourceKeyColumn); + $value = $sourceClass->reflFields[$field]->getValue($sourceEntity); + + if (isset($sourceClass->associationMappings[$field])) { + $value = $this->em->getUnitOfWork()->getEntityIdentifier($value); + $value = $value[$this->em->getClassMetadata($sourceClass->associationMappings[$field]->targetEntity)->identifier[0]]; + } + + $criteria[$tableAlias . '.' . $targetKeyColumn] = $value; + $parameters[] = [ + 'value' => $value, + 'field' => $field, + 'class' => $sourceClass, + ]; + + continue; + } + + $field = $sourceClass->fieldNames[$sourceKeyColumn]; + $value = $sourceClass->reflFields[$field]->getValue($sourceEntity); + + $criteria[$tableAlias . '.' . $targetKeyColumn] = $value; + $parameters[] = [ + 'value' => $value, + 'field' => $field, + 'class' => $sourceClass, + ]; + } + + $sql = $this->getSelectSQL($criteria, $assoc, null, $limit, $offset); + [$params, $types] = $this->expandToManyParameters($parameters); + + return $this->conn->executeQuery($sql, $params, $types); + } + + /** + * {@inheritDoc} + */ + public function expandParameters(array $criteria): array + { + $params = []; + $types = []; + + foreach ($criteria as $field => $value) { + if ($value === null) { + continue; // skip null values. + } + + $types = [...$types, ...$this->getTypes($field, $value, $this->class)]; + $params = array_merge($params, $this->getValues($value)); + } + + return [$params, $types]; + } + + /** + * Expands the parameters from the given criteria and use the correct binding types if found, + * specialized for OneToMany or ManyToMany associations. + * + * @param mixed[][] $criteria an array of arrays containing following: + * - field to which each criterion will be bound + * - value to be bound + * - class to which the field belongs to + * + * @return mixed[][] + * @psalm-return array{0: array, 1: list} + */ + private function expandToManyParameters(array $criteria): array + { + $params = []; + $types = []; + + foreach ($criteria as $criterion) { + if ($criterion['value'] === null) { + continue; // skip null values. + } + + $types = [...$types, ...$this->getTypes($criterion['field'], $criterion['value'], $criterion['class'])]; + $params = array_merge($params, $this->getValues($criterion['value'])); + } + + return [$params, $types]; + } + + /** + * Infers field types to be used by parameter type casting. + * + * @return list + * @psalm-return list + * + * @throws QueryException + */ + private function getTypes(string $field, mixed $value, ClassMetadata $class): array + { + $types = []; + + switch (true) { + case isset($class->fieldMappings[$field]): + $types = array_merge($types, [$class->fieldMappings[$field]->type]); + break; + + case isset($class->associationMappings[$field]): + $assoc = $this->em->getMetadataFactory()->getOwningSide($class->associationMappings[$field]); + $class = $this->em->getClassMetadata($assoc->targetEntity); + + if ($assoc->isManyToManyOwningSide()) { + $columns = $assoc->relationToTargetKeyColumns; + } else { + assert($assoc->isToOneOwningSide()); + $columns = $assoc->sourceToTargetKeyColumns; + } + + foreach ($columns as $column) { + $types[] = PersisterHelper::getTypeOfColumn($column, $class, $this->em); + } + + break; + + default: + $types[] = ParameterType::STRING; + break; + } + + if (is_array($value)) { + return array_map($this->getArrayBindingType(...), $types); + } + + return $types; + } + + /** @psalm-return ArrayParameterType::* */ + private function getArrayBindingType(ParameterType|int|string $type): ArrayParameterType|int + { + if (! $type instanceof ParameterType) { + $type = Type::getType((string) $type)->getBindingType(); + } + + return match ($type) { + ParameterType::STRING => ArrayParameterType::STRING, + ParameterType::INTEGER => ArrayParameterType::INTEGER, + ParameterType::ASCII => ArrayParameterType::ASCII, + }; + } + + /** + * Retrieves the parameters that identifies a value. + * + * @return mixed[] + */ + private function getValues(mixed $value): array + { + if (is_array($value)) { + $newValue = []; + + foreach ($value as $itemValue) { + $newValue = array_merge($newValue, $this->getValues($itemValue)); + } + + return [$newValue]; + } + + return $this->getIndividualValue($value); + } + + /** + * Retrieves an individual parameter value. + * + * @psalm-return list + */ + private function getIndividualValue(mixed $value): array + { + if (! is_object($value)) { + return [$value]; + } + + if ($value instanceof BackedEnum) { + return [$value->value]; + } + + $valueClass = DefaultProxyClassNameResolver::getClass($value); + + if ($this->em->getMetadataFactory()->isTransient($valueClass)) { + return [$value]; + } + + $class = $this->em->getClassMetadata($valueClass); + + if ($class->isIdentifierComposite) { + $newValue = []; + + foreach ($class->getIdentifierValues($value) as $innerValue) { + $newValue = array_merge($newValue, $this->getValues($innerValue)); + } + + return $newValue; + } + + return [$this->em->getUnitOfWork()->getSingleIdentifierValue($value)]; + } + + public function exists(object $entity, Criteria|null $extraConditions = null): bool + { + $criteria = $this->class->getIdentifierValues($entity); + + if (! $criteria) { + return false; + } + + $alias = $this->getSQLTableAlias($this->class->name); + + $sql = 'SELECT 1 ' + . $this->getLockTablesSql(LockMode::NONE) + . ' WHERE ' . $this->getSelectConditionSQL($criteria); + + [$params, $types] = $this->expandParameters($criteria); + + if ($extraConditions !== null) { + $sql .= ' AND ' . $this->getSelectConditionCriteriaSQL($extraConditions); + [$criteriaParams, $criteriaTypes] = $this->expandCriteriaParameters($extraConditions); + + $params = [...$params, ...$criteriaParams]; + $types = [...$types, ...$criteriaTypes]; + } + + $filterSql = $this->generateFilterConditionSQL($this->class, $alias); + if ($filterSql) { + $sql .= ' AND ' . $filterSql; + } + + return (bool) $this->conn->fetchOne($sql, $params, $types); + } + + /** + * Generates the appropriate join SQL for the given join column. + * + * @param list $joinColumns The join columns definition of an association. + * + * @return string LEFT JOIN if one of the columns is nullable, INNER JOIN otherwise. + */ + protected function getJoinSQLForJoinColumns(array $joinColumns): string + { + // if one of the join columns is nullable, return left join + foreach ($joinColumns as $joinColumn) { + if (! isset($joinColumn->nullable) || $joinColumn->nullable) { + return 'LEFT JOIN'; + } + } + + return 'INNER JOIN'; + } + + public function getSQLColumnAlias(string $columnName): string + { + return $this->quoteStrategy->getColumnAlias($columnName, $this->currentPersisterContext->sqlAliasCounter++, $this->platform); + } + + /** + * Generates the filter SQL for a given entity and table alias. + * + * @param ClassMetadata $targetEntity Metadata of the target entity. + * @param string $targetTableAlias The table alias of the joined/selected table. + * + * @return string The SQL query part to add to a query. + */ + protected function generateFilterConditionSQL(ClassMetadata $targetEntity, string $targetTableAlias): string + { + $filterClauses = []; + + foreach ($this->em->getFilters()->getEnabledFilters() as $filter) { + $filterExpr = $filter->addFilterConstraint($targetEntity, $targetTableAlias); + if ($filterExpr !== '') { + $filterClauses[] = '(' . $filterExpr . ')'; + } + } + + $sql = implode(' AND ', $filterClauses); + + return $sql ? '(' . $sql . ')' : ''; // Wrap again to avoid "X or Y and FilterConditionSQL" + } + + /** + * Switches persister context according to current query offset/limits + * + * This is due to the fact that to-many associations cannot be fetch-joined when a limit is involved + */ + protected function switchPersisterContext(int|null $offset, int|null $limit): void + { + if ($offset === null && $limit === null) { + $this->currentPersisterContext = $this->noLimitsContext; + + return; + } + + $this->currentPersisterContext = $this->limitsHandlingContext; + } + + /** + * @return string[] + * @psalm-return list + */ + protected function getClassIdentifiersTypes(ClassMetadata $class): array + { + $entityManager = $this->em; + + return array_map( + static function ($fieldName) use ($class, $entityManager): string { + $types = PersisterHelper::getTypeOfField($fieldName, $class, $entityManager); + assert(isset($types[0])); + + return $types[0]; + }, + $class->identifier, + ); + } +} diff --git a/vendor/doctrine/orm/src/Persisters/Entity/CachedPersisterContext.php b/vendor/doctrine/orm/src/Persisters/Entity/CachedPersisterContext.php new file mode 100644 index 0000000..03d053b --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Entity/CachedPersisterContext.php @@ -0,0 +1,60 @@ + + */ + public array $sqlTableAliases = []; + + public function __construct( + /** + * Metadata object that describes the mapping of the mapped entity class. + */ + public ClassMetadata $class, + /** + * ResultSetMapping that is used for all queries. Is generated lazily once per request. + */ + public ResultSetMapping $rsm, + /** + * Whether this persistent context is considering limit operations applied to the selection queries + */ + public bool $handlesLimits, + ) { + } +} diff --git a/vendor/doctrine/orm/src/Persisters/Entity/EntityPersister.php b/vendor/doctrine/orm/src/Persisters/Entity/EntityPersister.php new file mode 100644 index 0000000..6b278a7 --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Entity/EntityPersister.php @@ -0,0 +1,298 @@ +, list} + */ + public function expandParameters(array $criteria): array; + + /** + * Expands Criteria Parameters by walking the expressions and grabbing all parameters and types from it. + * + * @psalm-return array{list, list} + */ + public function expandCriteriaParameters(Criteria $criteria): array; + + /** Gets the SQL WHERE condition for matching a field with a given value. */ + public function getSelectConditionStatementSQL( + string $field, + mixed $value, + AssociationMapping|null $assoc = null, + string|null $comparison = null, + ): string; + + /** + * Adds an entity to the queued insertions. + * The entity remains queued until {@link executeInserts} is invoked. + */ + public function addInsert(object $entity): void; + + /** + * Executes all queued entity insertions. + * + * If no inserts are queued, invoking this method is a NOOP. + */ + public function executeInserts(): void; + + /** + * Updates a managed entity. The entity is updated according to its current changeset + * in the running UnitOfWork. If there is no changeset, nothing is updated. + */ + public function update(object $entity): void; + + /** + * Deletes a managed entity. + * + * The entity to delete must be managed and have a persistent identifier. + * The deletion happens instantaneously. + * + * Subclasses may override this method to customize the semantics of entity deletion. + * + * @return bool TRUE if the entity got deleted in the database, FALSE otherwise. + */ + public function delete(object $entity): bool; + + /** + * Count entities (optionally filtered by a criteria) + * + * @param mixed[]|Criteria $criteria + */ + public function count(array|Criteria $criteria = []): int; + + /** + * Gets the name of the table that owns the column the given field is mapped to. + * + * The default implementation in BasicEntityPersister always returns the name + * of the table the entity type of this persister is mapped to, since an entity + * is always persisted to a single table with a BasicEntityPersister. + */ + public function getOwningTable(string $fieldName): string; + + /** + * Loads an entity by a list of field criteria. + * + * @param mixed[] $criteria The criteria by which to load the entity. + * @param object|null $entity The entity to load the data into. If not specified, + * a new entity is created. + * @param AssociationMapping|null $assoc The association that connects the entity + * to load to another entity, if any. + * @param mixed[] $hints Hints for entity creation. + * @param LockMode|int|null $lockMode One of the \Doctrine\DBAL\LockMode::* constants + * or NULL if no specific lock mode should be used + * for loading the entity. + * @param int|null $limit Limit number of results. + * @param string[]|null $orderBy Criteria to order by. + * @psalm-param array $criteria + * @psalm-param array $hints + * @psalm-param LockMode::*|null $lockMode + * @psalm-param array|null $orderBy + * + * @return object|null The loaded and managed entity instance or NULL if the entity can not be found. + * + * @todo Check identity map? loadById method? Try to guess whether $criteria is the id? + */ + public function load( + array $criteria, + object|null $entity = null, + AssociationMapping|null $assoc = null, + array $hints = [], + LockMode|int|null $lockMode = null, + int|null $limit = null, + array|null $orderBy = null, + ): object|null; + + /** + * Loads an entity by identifier. + * + * @param object|null $entity The entity to load the data into. If not specified, a new entity is created. + * @psalm-param array $identifier The entity identifier. + * + * @return object|null The loaded and managed entity instance or NULL if the entity can not be found. + * + * @todo Check parameters + */ + public function loadById(array $identifier, object|null $entity = null): object|null; + + /** + * Loads an entity of this persister's mapped class as part of a single-valued + * association from another entity. + * + * @param AssociationMapping $assoc The association to load. + * @param object $sourceEntity The entity that owns the association (not necessarily the "owning side"). + * @psalm-param array $identifier The identifier of the entity to load. Must be provided if + * the association to load represents the owning side, otherwise + * the identifier is derived from the $sourceEntity. + * + * @return object|null The loaded and managed entity instance or NULL if the entity can not be found. + * + * @throws MappingException + */ + public function loadOneToOneEntity(AssociationMapping $assoc, object $sourceEntity, array $identifier = []): object|null; + + /** + * Refreshes a managed entity. + * + * @param LockMode|int|null $lockMode One of the \Doctrine\DBAL\LockMode::* constants + * or NULL if no specific lock mode should be used + * for refreshing the managed entity. + * @psalm-param array $id The identifier of the entity as an + * associative array from column or + * field names to values. + * @psalm-param LockMode::*|null $lockMode + */ + public function refresh(array $id, object $entity, LockMode|int|null $lockMode = null): void; + + /** + * Loads Entities matching the given Criteria object. + * + * @return mixed[] + */ + public function loadCriteria(Criteria $criteria): array; + + /** + * Loads a list of entities by a list of field criteria. + * + * @psalm-param array|null $orderBy + * @psalm-param array $criteria + * + * @return mixed[] + */ + public function loadAll( + array $criteria = [], + array|null $orderBy = null, + int|null $limit = null, + int|null $offset = null, + ): array; + + /** + * Gets (sliced or full) elements of the given collection. + * + * @return mixed[] + */ + public function getManyToManyCollection( + AssociationMapping $assoc, + object $sourceEntity, + int|null $offset = null, + int|null $limit = null, + ): array; + + /** + * Loads a collection of entities of a many-to-many association. + * + * @param AssociationMapping $assoc The association mapping of the association being loaded. + * @param object $sourceEntity The entity that owns the collection. + * @param PersistentCollection $collection The collection to fill. + * + * @return mixed[] + */ + public function loadManyToManyCollection( + AssociationMapping $assoc, + object $sourceEntity, + PersistentCollection $collection, + ): array; + + /** + * Loads a collection of entities in a one-to-many association. + * + * @param PersistentCollection $collection The collection to load/fill. + */ + public function loadOneToManyCollection( + AssociationMapping $assoc, + object $sourceEntity, + PersistentCollection $collection, + ): mixed; + + /** + * Locks all rows of this entity matching the given criteria with the specified pessimistic lock mode. + * + * @psalm-param array $criteria + * @psalm-param LockMode::* $lockMode + */ + public function lock(array $criteria, LockMode|int $lockMode): void; + + /** + * Returns an array with (sliced or full list) of elements in the specified collection. + * + * @return mixed[] + */ + public function getOneToManyCollection( + AssociationMapping $assoc, + object $sourceEntity, + int|null $offset = null, + int|null $limit = null, + ): array; + + /** + * Checks whether the given managed entity exists in the database. + */ + public function exists(object $entity, Criteria|null $extraConditions = null): bool; +} diff --git a/vendor/doctrine/orm/src/Persisters/Entity/JoinedSubclassPersister.php b/vendor/doctrine/orm/src/Persisters/Entity/JoinedSubclassPersister.php new file mode 100644 index 0000000..76719a2 --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Entity/JoinedSubclassPersister.php @@ -0,0 +1,601 @@ +Class Table Inheritance strategy. + * + * @see https://martinfowler.com/eaaCatalog/classTableInheritance.html + */ +class JoinedSubclassPersister extends AbstractEntityInheritancePersister +{ + use LockSqlHelper; + use SQLResultCasing; + + /** + * Map that maps column names to the table names that own them. + * This is mainly a temporary cache, used during a single request. + * + * @psalm-var array + */ + private array $owningTableMap = []; + + /** + * Map of table to quoted table names. + * + * @psalm-var array + */ + private array $quotedTableMap = []; + + protected function getDiscriminatorColumnTableName(): string + { + $class = $this->class->name !== $this->class->rootEntityName + ? $this->em->getClassMetadata($this->class->rootEntityName) + : $this->class; + + return $class->getTableName(); + } + + /** + * This function finds the ClassMetadata instance in an inheritance hierarchy + * that is responsible for enabling versioning. + */ + private function getVersionedClassMetadata(): ClassMetadata + { + if (isset($this->class->fieldMappings[$this->class->versionField]->inherited)) { + $definingClassName = $this->class->fieldMappings[$this->class->versionField]->inherited; + + return $this->em->getClassMetadata($definingClassName); + } + + return $this->class; + } + + /** + * Gets the name of the table that owns the column the given field is mapped to. + */ + public function getOwningTable(string $fieldName): string + { + if (isset($this->owningTableMap[$fieldName])) { + return $this->owningTableMap[$fieldName]; + } + + $cm = match (true) { + isset($this->class->associationMappings[$fieldName]->inherited) + => $this->em->getClassMetadata($this->class->associationMappings[$fieldName]->inherited), + isset($this->class->fieldMappings[$fieldName]->inherited) + => $this->em->getClassMetadata($this->class->fieldMappings[$fieldName]->inherited), + default => $this->class, + }; + + $tableName = $cm->getTableName(); + $quotedTableName = $this->quoteStrategy->getTableName($cm, $this->platform); + + $this->owningTableMap[$fieldName] = $tableName; + $this->quotedTableMap[$tableName] = $quotedTableName; + + return $tableName; + } + + public function executeInserts(): void + { + if (! $this->queuedInserts) { + return; + } + + $uow = $this->em->getUnitOfWork(); + $idGenerator = $this->class->idGenerator; + $isPostInsertId = $idGenerator->isPostInsertGenerator(); + $rootClass = $this->class->name !== $this->class->rootEntityName + ? $this->em->getClassMetadata($this->class->rootEntityName) + : $this->class; + + // Prepare statement for the root table + $rootPersister = $this->em->getUnitOfWork()->getEntityPersister($rootClass->name); + $rootTableName = $rootClass->getTableName(); + $rootTableStmt = $this->conn->prepare($rootPersister->getInsertSQL()); + + // Prepare statements for sub tables. + $subTableStmts = []; + + if ($rootClass !== $this->class) { + $subTableStmts[$this->class->getTableName()] = $this->conn->prepare($this->getInsertSQL()); + } + + foreach ($this->class->parentClasses as $parentClassName) { + $parentClass = $this->em->getClassMetadata($parentClassName); + $parentTableName = $parentClass->getTableName(); + + if ($parentClass !== $rootClass) { + $parentPersister = $this->em->getUnitOfWork()->getEntityPersister($parentClassName); + $subTableStmts[$parentTableName] = $this->conn->prepare($parentPersister->getInsertSQL()); + } + } + + // Execute all inserts. For each entity: + // 1) Insert on root table + // 2) Insert on sub tables + foreach ($this->queuedInserts as $entity) { + $insertData = $this->prepareInsertData($entity); + + // Execute insert on root table + $paramIndex = 1; + + foreach ($insertData[$rootTableName] as $columnName => $value) { + $rootTableStmt->bindValue($paramIndex++, $value, $this->columnTypes[$columnName]); + } + + $rootTableStmt->executeStatement(); + + if ($isPostInsertId) { + $generatedId = $idGenerator->generateId($this->em, $entity); + $id = [$this->class->identifier[0] => $generatedId]; + + $uow->assignPostInsertId($entity, $generatedId); + } else { + $id = $this->em->getUnitOfWork()->getEntityIdentifier($entity); + } + + // Execute inserts on subtables. + // The order doesn't matter because all child tables link to the root table via FK. + foreach ($subTableStmts as $tableName => $stmt) { + $paramIndex = 1; + $data = $insertData[$tableName] ?? []; + + foreach ($id as $idName => $idVal) { + $type = $this->columnTypes[$idName] ?? Types::STRING; + + $stmt->bindValue($paramIndex++, $idVal, $type); + } + + foreach ($data as $columnName => $value) { + if (! isset($id[$columnName])) { + $stmt->bindValue($paramIndex++, $value, $this->columnTypes[$columnName]); + } + } + + $stmt->executeStatement(); + } + + if ($this->class->requiresFetchAfterChange) { + $this->assignDefaultVersionAndUpsertableValues($entity, $id); + } + } + + $this->queuedInserts = []; + } + + public function update(object $entity): void + { + $updateData = $this->prepareUpdateData($entity); + + if (! $updateData) { + return; + } + + $isVersioned = $this->class->isVersioned; + + $versionedClass = $this->getVersionedClassMetadata(); + $versionedTable = $versionedClass->getTableName(); + + foreach ($updateData as $tableName => $data) { + $tableName = $this->quotedTableMap[$tableName]; + $versioned = $isVersioned && $versionedTable === $tableName; + + $this->updateTable($entity, $tableName, $data, $versioned); + } + + if ($this->class->requiresFetchAfterChange) { + // Make sure the table with the version column is updated even if no columns on that + // table were affected. + if ($isVersioned && ! isset($updateData[$versionedTable])) { + $tableName = $this->quoteStrategy->getTableName($versionedClass, $this->platform); + + $this->updateTable($entity, $tableName, [], true); + } + + $identifiers = $this->em->getUnitOfWork()->getEntityIdentifier($entity); + + $this->assignDefaultVersionAndUpsertableValues($entity, $identifiers); + } + } + + public function delete(object $entity): bool + { + $identifier = $this->em->getUnitOfWork()->getEntityIdentifier($entity); + $id = array_combine($this->class->getIdentifierColumnNames(), $identifier); + $types = $this->getClassIdentifiersTypes($this->class); + + $this->deleteJoinTableRecords($identifier, $types); + + // Delete the row from the root table. Cascades do the rest. + $rootClass = $this->em->getClassMetadata($this->class->rootEntityName); + $rootTable = $this->quoteStrategy->getTableName($rootClass, $this->platform); + $rootTypes = $this->getClassIdentifiersTypes($rootClass); + + return (bool) $this->conn->delete($rootTable, $id, $rootTypes); + } + + public function getSelectSQL( + array|Criteria $criteria, + AssociationMapping|null $assoc = null, + LockMode|int|null $lockMode = null, + int|null $limit = null, + int|null $offset = null, + array|null $orderBy = null, + ): string { + $this->switchPersisterContext($offset, $limit); + + $baseTableAlias = $this->getSQLTableAlias($this->class->name); + $joinSql = $this->getJoinSql($baseTableAlias); + + if ($assoc !== null && $assoc->isManyToMany()) { + $joinSql .= $this->getSelectManyToManyJoinSQL($assoc); + } + + $conditionSql = $criteria instanceof Criteria + ? $this->getSelectConditionCriteriaSQL($criteria) + : $this->getSelectConditionSQL($criteria, $assoc); + + $filterSql = $this->generateFilterConditionSQL( + $this->em->getClassMetadata($this->class->rootEntityName), + $this->getSQLTableAlias($this->class->rootEntityName), + ); + // If the current class in the root entity, add the filters + if ($filterSql) { + $conditionSql .= $conditionSql + ? ' AND ' . $filterSql + : $filterSql; + } + + $orderBySql = ''; + + if ($assoc !== null && $assoc->isOrdered()) { + $orderBy = $assoc->orderBy(); + } + + if ($orderBy) { + $orderBySql = $this->getOrderBySQL($orderBy, $baseTableAlias); + } + + $lockSql = ''; + + switch ($lockMode) { + case LockMode::PESSIMISTIC_READ: + $lockSql = ' ' . $this->getReadLockSQL($this->platform); + + break; + + case LockMode::PESSIMISTIC_WRITE: + $lockSql = ' ' . $this->getWriteLockSQL($this->platform); + + break; + } + + $tableName = $this->quoteStrategy->getTableName($this->class, $this->platform); + $from = ' FROM ' . $tableName . ' ' . $baseTableAlias; + $where = $conditionSql !== '' ? ' WHERE ' . $conditionSql : ''; + $lock = $this->platform->appendLockHint($from, $lockMode ?? LockMode::NONE); + $columnList = $this->getSelectColumnsSQL(); + $query = 'SELECT ' . $columnList + . $lock + . $joinSql + . $where + . $orderBySql; + + return $this->platform->modifyLimitQuery($query, $limit, $offset ?? 0) . $lockSql; + } + + public function getCountSQL(array|Criteria $criteria = []): string + { + $tableName = $this->quoteStrategy->getTableName($this->class, $this->platform); + $baseTableAlias = $this->getSQLTableAlias($this->class->name); + $joinSql = $this->getJoinSql($baseTableAlias); + + $conditionSql = $criteria instanceof Criteria + ? $this->getSelectConditionCriteriaSQL($criteria) + : $this->getSelectConditionSQL($criteria); + + $filterSql = $this->generateFilterConditionSQL($this->em->getClassMetadata($this->class->rootEntityName), $this->getSQLTableAlias($this->class->rootEntityName)); + + if ($filterSql !== '') { + $conditionSql = $conditionSql + ? $conditionSql . ' AND ' . $filterSql + : $filterSql; + } + + return 'SELECT COUNT(*) ' + . 'FROM ' . $tableName . ' ' . $baseTableAlias + . $joinSql + . (empty($conditionSql) ? '' : ' WHERE ' . $conditionSql); + } + + protected function getLockTablesSql(LockMode|int $lockMode): string + { + $joinSql = ''; + $identifierColumns = $this->class->getIdentifierColumnNames(); + $baseTableAlias = $this->getSQLTableAlias($this->class->name); + + // INNER JOIN parent tables + foreach ($this->class->parentClasses as $parentClassName) { + $conditions = []; + $tableAlias = $this->getSQLTableAlias($parentClassName); + $parentClass = $this->em->getClassMetadata($parentClassName); + $joinSql .= ' INNER JOIN ' . $this->quoteStrategy->getTableName($parentClass, $this->platform) . ' ' . $tableAlias . ' ON '; + + foreach ($identifierColumns as $idColumn) { + $conditions[] = $baseTableAlias . '.' . $idColumn . ' = ' . $tableAlias . '.' . $idColumn; + } + + $joinSql .= implode(' AND ', $conditions); + } + + return parent::getLockTablesSql($lockMode) . $joinSql; + } + + /** + * Ensure this method is never called. This persister overrides getSelectEntitiesSQL directly. + */ + protected function getSelectColumnsSQL(): string + { + // Create the column list fragment only once + if ($this->currentPersisterContext->selectColumnListSql !== null) { + return $this->currentPersisterContext->selectColumnListSql; + } + + $columnList = []; + $discrColumn = $this->class->getDiscriminatorColumn(); + $discrColumnName = $discrColumn->name; + $discrColumnType = $discrColumn->type; + $baseTableAlias = $this->getSQLTableAlias($this->class->name); + $resultColumnName = $this->getSQLResultCasing($this->platform, $discrColumnName); + + $this->currentPersisterContext->rsm->addEntityResult($this->class->name, 'r'); + $this->currentPersisterContext->rsm->setDiscriminatorColumn('r', $resultColumnName); + $this->currentPersisterContext->rsm->addMetaResult('r', $resultColumnName, $discrColumnName, false, $discrColumnType); + + // Add regular columns + foreach ($this->class->fieldMappings as $fieldName => $mapping) { + $class = isset($mapping->inherited) + ? $this->em->getClassMetadata($mapping->inherited) + : $this->class; + + $columnList[] = $this->getSelectColumnSQL($fieldName, $class); + } + + // Add foreign key columns + foreach ($this->class->associationMappings as $mapping) { + if (! $mapping->isToOneOwningSide()) { + continue; + } + + $tableAlias = isset($mapping->inherited) + ? $this->getSQLTableAlias($mapping->inherited) + : $baseTableAlias; + + $targetClass = $this->em->getClassMetadata($mapping->targetEntity); + + foreach ($mapping->joinColumns as $joinColumn) { + $columnList[] = $this->getSelectJoinColumnSQL( + $tableAlias, + $joinColumn->name, + $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform), + PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $targetClass, $this->em), + ); + } + } + + // Add discriminator column (DO NOT ALIAS, see AbstractEntityInheritancePersister#processSQLResult). + $tableAlias = $this->class->rootEntityName === $this->class->name + ? $baseTableAlias + : $this->getSQLTableAlias($this->class->rootEntityName); + + $columnList[] = $tableAlias . '.' . $discrColumnName; + + // sub tables + foreach ($this->class->subClasses as $subClassName) { + $subClass = $this->em->getClassMetadata($subClassName); + $tableAlias = $this->getSQLTableAlias($subClassName); + + // Add subclass columns + foreach ($subClass->fieldMappings as $fieldName => $mapping) { + if (isset($mapping->inherited)) { + continue; + } + + $columnList[] = $this->getSelectColumnSQL($fieldName, $subClass); + } + + // Add join columns (foreign keys) + foreach ($subClass->associationMappings as $mapping) { + if (! $mapping->isToOneOwningSide() || isset($mapping->inherited)) { + continue; + } + + $targetClass = $this->em->getClassMetadata($mapping->targetEntity); + + foreach ($mapping->joinColumns as $joinColumn) { + $columnList[] = $this->getSelectJoinColumnSQL( + $tableAlias, + $joinColumn->name, + $this->quoteStrategy->getJoinColumnName($joinColumn, $subClass, $this->platform), + PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $targetClass, $this->em), + ); + } + } + } + + $this->currentPersisterContext->selectColumnListSql = implode(', ', $columnList); + + return $this->currentPersisterContext->selectColumnListSql; + } + + /** + * {@inheritDoc} + */ + protected function getInsertColumnList(): array + { + // Identifier columns must always come first in the column list of subclasses. + $columns = $this->class->parentClasses + ? $this->class->getIdentifierColumnNames() + : []; + + foreach ($this->class->reflFields as $name => $field) { + if ( + isset($this->class->fieldMappings[$name]->inherited) + && ! isset($this->class->fieldMappings[$name]->id) + || isset($this->class->associationMappings[$name]->inherited) + || ($this->class->isVersioned && $this->class->versionField === $name) + || isset($this->class->embeddedClasses[$name]) + || isset($this->class->fieldMappings[$name]->notInsertable) + ) { + continue; + } + + if (isset($this->class->associationMappings[$name])) { + $assoc = $this->class->associationMappings[$name]; + if ($assoc->isToOneOwningSide()) { + foreach ($assoc->targetToSourceKeyColumns as $sourceCol) { + $columns[] = $sourceCol; + } + } + } elseif ( + $this->class->name !== $this->class->rootEntityName || + ! $this->class->isIdGeneratorIdentity() || $this->class->identifier[0] !== $name + ) { + $columns[] = $this->quoteStrategy->getColumnName($name, $this->class, $this->platform); + $this->columnTypes[$name] = $this->class->fieldMappings[$name]->type; + } + } + + // Add discriminator column if it is the topmost class. + if ($this->class->name === $this->class->rootEntityName) { + $columns[] = $this->class->getDiscriminatorColumn()->name; + } + + return $columns; + } + + /** + * {@inheritDoc} + */ + protected function assignDefaultVersionAndUpsertableValues(object $entity, array $id): void + { + $values = $this->fetchVersionAndNotUpsertableValues($this->getVersionedClassMetadata(), $id); + + foreach ($values as $field => $value) { + $value = Type::getType($this->class->fieldMappings[$field]->type)->convertToPHPValue($value, $this->platform); + + $this->class->setFieldValue($entity, $field, $value); + } + } + + /** + * {@inheritDoc} + */ + protected function fetchVersionAndNotUpsertableValues(ClassMetadata $versionedClass, array $id): mixed + { + $columnNames = []; + foreach ($this->class->fieldMappings as $key => $column) { + $class = null; + if ($this->class->isVersioned && $key === $versionedClass->versionField) { + $class = $versionedClass; + } elseif (isset($column->generated)) { + $class = isset($column->inherited) + ? $this->em->getClassMetadata($column->inherited) + : $this->class; + } else { + continue; + } + + $columnNames[$key] = $this->getSelectColumnSQL($key, $class); + } + + $tableName = $this->quoteStrategy->getTableName($versionedClass, $this->platform); + $baseTableAlias = $this->getSQLTableAlias($this->class->name); + $joinSql = $this->getJoinSql($baseTableAlias); + $identifier = $this->quoteStrategy->getIdentifierColumnNames($versionedClass, $this->platform); + foreach ($identifier as $i => $idValue) { + $identifier[$i] = $baseTableAlias . '.' . $idValue; + } + + $sql = 'SELECT ' . implode(', ', $columnNames) + . ' FROM ' . $tableName . ' ' . $baseTableAlias + . $joinSql + . ' WHERE ' . implode(' = ? AND ', $identifier) . ' = ?'; + + $flatId = $this->identifierFlattener->flattenIdentifier($versionedClass, $id); + $values = $this->conn->fetchNumeric( + $sql, + array_values($flatId), + $this->extractIdentifierTypes($id, $versionedClass), + ); + + if ($values === false) { + throw new LengthException('Unexpected empty result for database query.'); + } + + $values = array_combine(array_keys($columnNames), $values); + + if (! $values) { + throw new LengthException('Unexpected number of database columns.'); + } + + return $values; + } + + private function getJoinSql(string $baseTableAlias): string + { + $joinSql = ''; + $identifierColumn = $this->class->getIdentifierColumnNames(); + + // INNER JOIN parent tables + foreach ($this->class->parentClasses as $parentClassName) { + $conditions = []; + $parentClass = $this->em->getClassMetadata($parentClassName); + $tableAlias = $this->getSQLTableAlias($parentClassName); + $joinSql .= ' INNER JOIN ' . $this->quoteStrategy->getTableName($parentClass, $this->platform) . ' ' . $tableAlias . ' ON '; + + foreach ($identifierColumn as $idColumn) { + $conditions[] = $baseTableAlias . '.' . $idColumn . ' = ' . $tableAlias . '.' . $idColumn; + } + + $joinSql .= implode(' AND ', $conditions); + } + + // OUTER JOIN sub tables + foreach ($this->class->subClasses as $subClassName) { + $conditions = []; + $subClass = $this->em->getClassMetadata($subClassName); + $tableAlias = $this->getSQLTableAlias($subClassName); + $joinSql .= ' LEFT JOIN ' . $this->quoteStrategy->getTableName($subClass, $this->platform) . ' ' . $tableAlias . ' ON '; + + foreach ($identifierColumn as $idColumn) { + $conditions[] = $baseTableAlias . '.' . $idColumn . ' = ' . $tableAlias . '.' . $idColumn; + } + + $joinSql .= implode(' AND ', $conditions); + } + + return $joinSql; + } +} diff --git a/vendor/doctrine/orm/src/Persisters/Entity/SingleTablePersister.php b/vendor/doctrine/orm/src/Persisters/Entity/SingleTablePersister.php new file mode 100644 index 0000000..4a4d999 --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Entity/SingleTablePersister.php @@ -0,0 +1,166 @@ +class->getTableName(); + } + + protected function getSelectColumnsSQL(): string + { + $columnList = []; + if ($this->currentPersisterContext->selectColumnListSql !== null) { + return $this->currentPersisterContext->selectColumnListSql; + } + + $columnList[] = parent::getSelectColumnsSQL(); + + $rootClass = $this->em->getClassMetadata($this->class->rootEntityName); + $tableAlias = $this->getSQLTableAlias($rootClass->name); + + // Append discriminator column + $discrColumn = $this->class->getDiscriminatorColumn(); + $discrColumnName = $discrColumn->name; + $discrColumnType = $discrColumn->type; + + $columnList[] = $tableAlias . '.' . $discrColumnName; + + $resultColumnName = $this->getSQLResultCasing($this->platform, $discrColumnName); + + $this->currentPersisterContext->rsm->setDiscriminatorColumn('r', $resultColumnName); + $this->currentPersisterContext->rsm->addMetaResult('r', $resultColumnName, $discrColumnName, false, $discrColumnType); + + // Append subclass columns + foreach ($this->class->subClasses as $subClassName) { + $subClass = $this->em->getClassMetadata($subClassName); + + // Regular columns + foreach ($subClass->fieldMappings as $fieldName => $mapping) { + if (isset($mapping->inherited)) { + continue; + } + + $columnList[] = $this->getSelectColumnSQL($fieldName, $subClass); + } + + // Foreign key columns + foreach ($subClass->associationMappings as $assoc) { + if (! $assoc->isToOneOwningSide() || isset($assoc->inherited)) { + continue; + } + + $targetClass = $this->em->getClassMetadata($assoc->targetEntity); + + foreach ($assoc->joinColumns as $joinColumn) { + $columnList[] = $this->getSelectJoinColumnSQL( + $tableAlias, + $joinColumn->name, + $this->quoteStrategy->getJoinColumnName($joinColumn, $subClass, $this->platform), + PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $targetClass, $this->em), + ); + } + } + } + + $this->currentPersisterContext->selectColumnListSql = implode(', ', $columnList); + + return $this->currentPersisterContext->selectColumnListSql; + } + + /** + * {@inheritDoc} + */ + protected function getInsertColumnList(): array + { + $columns = parent::getInsertColumnList(); + + // Add discriminator column to the INSERT SQL + $columns[] = $this->class->getDiscriminatorColumn()->name; + + return $columns; + } + + protected function getSQLTableAlias(string $className, string $assocName = ''): string + { + return parent::getSQLTableAlias($this->class->rootEntityName, $assocName); + } + + /** + * {@inheritDoc} + */ + protected function getSelectConditionSQL(array $criteria, AssociationMapping|null $assoc = null): string + { + $conditionSql = parent::getSelectConditionSQL($criteria, $assoc); + + if ($conditionSql) { + $conditionSql .= ' AND '; + } + + return $conditionSql . $this->getSelectConditionDiscriminatorValueSQL(); + } + + protected function getSelectConditionCriteriaSQL(Criteria $criteria): string + { + $conditionSql = parent::getSelectConditionCriteriaSQL($criteria); + + if ($conditionSql) { + $conditionSql .= ' AND '; + } + + return $conditionSql . $this->getSelectConditionDiscriminatorValueSQL(); + } + + protected function getSelectConditionDiscriminatorValueSQL(): string + { + $values = array_map($this->conn->quote(...), array_map( + strval(...), + array_flip(array_intersect($this->class->discriminatorMap, $this->class->subClasses)), + )); + + if ($this->class->discriminatorValue !== null) { // discriminators can be 0 + array_unshift($values, $this->conn->quote((string) $this->class->discriminatorValue)); + } + + $discColumnName = $this->class->getDiscriminatorColumn()->name; + + $values = implode(', ', $values); + $tableAlias = $this->getSQLTableAlias($this->class->name); + + return $tableAlias . '.' . $discColumnName . ' IN (' . $values . ')'; + } + + protected function generateFilterConditionSQL(ClassMetadata $targetEntity, string $targetTableAlias): string + { + // Ensure that the filters are applied to the root entity of the inheritance tree + $targetEntity = $this->em->getClassMetadata($targetEntity->rootEntityName); + // we don't care about the $targetTableAlias, in a STI there is only one table. + + return parent::generateFilterConditionSQL($targetEntity, $targetTableAlias); + } +} diff --git a/vendor/doctrine/orm/src/Persisters/Exception/CantUseInOperatorOnCompositeKeys.php b/vendor/doctrine/orm/src/Persisters/Exception/CantUseInOperatorOnCompositeKeys.php new file mode 100644 index 0000000..5c91312 --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Exception/CantUseInOperatorOnCompositeKeys.php @@ -0,0 +1,15 @@ +getField(); + $value = $comparison->getValue()->getValue(); // shortcut for walkValue() + + if ( + isset($this->classMetadata->associationMappings[$field]) && + $value !== null && + ! is_object($value) && + ! in_array($comparison->getOperator(), [Comparison::IN, Comparison::NIN], true) + ) { + throw MatchingAssociationFieldRequiresObject::fromClassAndAssociation( + $this->classMetadata->name, + $field, + ); + } + + return $this->persister->getSelectConditionStatementSQL($field, $value, null, $comparison->getOperator()); + } + + /** + * Converts a composite expression into the target query language output. + * + * @throws RuntimeException + */ + public function walkCompositeExpression(CompositeExpression $expr): string + { + $expressionList = []; + + foreach ($expr->getExpressionList() as $child) { + $expressionList[] = $this->dispatch($child); + } + + return match ($expr->getType()) { + CompositeExpression::TYPE_AND => '(' . implode(' AND ', $expressionList) . ')', + CompositeExpression::TYPE_OR => '(' . implode(' OR ', $expressionList) . ')', + CompositeExpression::TYPE_NOT => 'NOT (' . $expressionList[0] . ')', + default => throw new RuntimeException('Unknown composite ' . $expr->getType()), + }; + } + + /** + * Converts a value expression into the target query language part. + */ + public function walkValue(Value $value): string + { + return '?'; + } +} diff --git a/vendor/doctrine/orm/src/Persisters/SqlValueVisitor.php b/vendor/doctrine/orm/src/Persisters/SqlValueVisitor.php new file mode 100644 index 0000000..7f987ad --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/SqlValueVisitor.php @@ -0,0 +1,88 @@ +getValueFromComparison($comparison); + + $this->values[] = $value; + $this->types[] = [$comparison->getField(), $value, $comparison->getOperator()]; + + return null; + } + + /** + * Converts a composite expression into the target query language output. + * + * {@inheritDoc} + */ + public function walkCompositeExpression(CompositeExpression $expr) + { + foreach ($expr->getExpressionList() as $child) { + $this->dispatch($child); + } + + return null; + } + + /** + * Converts a value expression into the target query language part. + * + * {@inheritDoc} + */ + public function walkValue(Value $value) + { + return null; + } + + /** + * Returns the Parameters and Types necessary for matching the last visited expression. + * + * @return mixed[][] + * @psalm-return array{0: array, 1: array>} + */ + public function getParamsAndTypes(): array + { + return [$this->values, $this->types]; + } + + /** + * Returns the value from a Comparison. In case of a CONTAINS comparison, + * the value is wrapped in %-signs, because it will be used in a LIKE clause. + */ + protected function getValueFromComparison(Comparison $comparison): mixed + { + $value = $comparison->getValue()->getValue(); + + return match ($comparison->getOperator()) { + Comparison::CONTAINS => '%' . $value . '%', + Comparison::STARTS_WITH => $value . '%', + Comparison::ENDS_WITH => '%' . $value, + default => $value, + }; + } +} -- cgit v1.2.3