<?php
declare(strict_types=1);
namespace Doctrine\ORM\Persisters\Entity;
use BackedEnum;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Expr\Comparison;
use Doctrine\Common\Util\ClassUtils;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\LockMode;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Result;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\Types;
use Doctrine\Deprecations\Deprecation;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\MappingException;
use Doctrine\ORM\Mapping\QuoteStrategy;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\Persisters\Exception\CantUseInOperatorOnCompositeKeys;
use Doctrine\ORM\Persisters\Exception\InvalidOrientation;
use Doctrine\ORM\Persisters\Exception\UnrecognizedField;
use Doctrine\ORM\Persisters\SqlExpressionVisitor;
use Doctrine\ORM\Persisters\SqlValueVisitor;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\QueryException;
use Doctrine\ORM\Repository\Exception\InvalidFindByCall;
use Doctrine\ORM\UnitOfWork;
use Doctrine\ORM\Utility\IdentifierFlattener;
use Doctrine\ORM\Utility\PersisterHelper;
use LengthException;
use function array_combine;
use function array_keys;
use function array_map;
use function array_merge;
use function array_search;
use function array_unique;
use function array_values;
use function assert;
use function count;
use function implode;
use function is_array;
use function is_object;
use function reset;
use function spl_object_id;
use function sprintf;
use function str_contains;
use function strtoupper;
use function trim;
/**
* A BasicEntityPersister maps an entity to a single table in a relational database.
*
* A persister is always responsible for a single entity type.
*
* EntityPersisters are used during a UnitOfWork to apply any changes to the persistent
* state of entities onto a relational database when the UnitOfWork is committed,
* as well as for basic querying of entities and their associations (not DQL).
*
* The persisting operations that are invoked during a commit of a UnitOfWork to
* persist the persistent entity state are:
*
* - {@link addInsert} : To schedule an entity for insertion.
* - {@link executeInserts} : To execute all scheduled insertions.
* - {@link update} : To update the persistent state of an entity.
* - {@link delete} : To delete the persistent state of an entity.
*
* As can be seen from the above list, insertions are batched and executed all at once
* for increased efficiency.
*
* The querying operations invoked during a UnitOfWork, either through direct find
* requests or lazy-loading, are the following:
*
* - {@link load} : Loads (the state of) a single, managed entity.
* - {@link loadAll} : Loads multiple, managed entities.
* - {@link loadOneToOneEntity} : Loads a one/many-to-one entity association (lazy-loading).
* - {@link loadOneToManyCollection} : Loads a one-to-many entity association (lazy-loading).
* - {@link loadManyToManyCollection} : Loads a many-to-many entity association (lazy-loading).
*
* The BasicEntityPersister implementation provides the default behavior for
* persisting and querying entities that are mapped to a single database table.
*
* Subclasses can be created to provide custom persisting and querying strategies,
* i.e. spanning multiple tables.
*
* @psalm-import-type AssociationMapping from ClassMetadata
*/
class BasicEntityPersister implements EntityPersister
{
/** @var array<string,string> */
private static $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',
];
/**
* Metadata object that describes the mapping of the mapped entity class.
*
* @var ClassMetadata
*/
protected $class;
/**
* The underlying DBAL Connection of the used EntityManager.
*
* @var Connection $conn
*/
protected $conn;
/**
* The database platform.
*
* @var AbstractPlatform
*/
protected $platform;
/**
* The EntityManager instance.
*
* @var EntityManagerInterface
*/
protected $em;
/**
* Queued inserts.
*
* @psalm-var array<int, object>
*/
protected $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 $columnTypes = [];
/**
* The map of quoted column names.
*
* @see prepareInsertData($entity)
* @see prepareUpdateData($entity)
*
* @var mixed[]
*/
protected $quotedColumns = [];
/**
* The INSERT SQL statement used for entities handled by this persister.
* This SQL is only generated once per request, if at all.
*
* @var string|null
*/
private $insertSql;
/**
* The quote strategy.
*
* @var QuoteStrategy
*/
protected $quoteStrategy;
/**
* The IdentifierFlattener used for manipulating identifiers
*
* @var IdentifierFlattener
*/
protected $identifierFlattener;
/** @var CachedPersisterContext */
protected $currentPersisterContext;
/** @var CachedPersisterContext */
private $limitsHandlingContext;
/** @var CachedPersisterContext */
private $noLimitsContext;
/**
* Initializes a new <tt>BasicEntityPersister</tt> that uses the given EntityManager
* and persists instances of the class described by the given ClassMetadata descriptor.
*/
public function __construct(EntityManagerInterface $em, ClassMetadata $class)
{
$this->em = $em;
$this->class = $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
);
}
/**
* {@inheritDoc}
*/
public function getClassMetadata()
{
return $this->class;
}
/**
* {@inheritDoc}
*/
public function getResultSetMapping()
{
return $this->currentPersisterContext->rsm;
}
/**
* {@inheritDoc}
*/
public function addInsert($entity)
{
$this->queuedInserts[spl_object_id($entity)] = $entity;
}
/**
* {@inheritDoc}
*/
public function getInserts()
{
return $this->queuedInserts;
}
/**
* {@inheritDoc}
*/
public function executeInserts()
{
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 object $entity
* @param mixed[] $id
*
* @return void
*/
protected function assignDefaultVersionAndUpsertableValues($entity, array $id)
{
$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 ClassMetadata $versionedClass
* @param mixed[] $id
*
* @return mixed
*/
protected function fetchVersionAndNotUpsertableValues($versionedClass, array $id)
{
$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 int[]|null[]|string[]
* @psalm-return list<int|string|null>
*/
final protected function extractIdentifierTypes(array $id, ClassMetadata $versionedClass): array
{
$types = [];
foreach ($id as $field => $value) {
$types = array_merge($types, $this->getTypes($field, $value, $versionedClass));
}
return $types;
}
/**
* {@inheritDoc}
*/
public function update($entity)
{
$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(
$entity,
$quotedTableName,
array $updateData,
$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]['requireSQLConversion'])) {
$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;
}
$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<mixed> $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['type'] !== ClassMetadata::MANY_TO_MANY || isset($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 = $class->associationMappings[$mapping['mappedBy']];
}
$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);
}
}
}
/**
* {@inheritDoc}
*/
public function delete($entity)
{
$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
* <columnName> => <value> pairs, grouped by table name.
*
* Example:
* <code>
* array(
* 'foo_table' => array('column1' => 'value1', 'column2' => 'value2', ...),
* 'bar_table' => array('columnX' => 'valueX', 'columnY' => 'valueY', ...),
* ...
* )
* </code>
*
* @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<string, array<array-key, mixed|null>>
*/
protected function prepareUpdateData($entity, bool $isInsert = false)
{
$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['isOwningSide'] || ! ($assoc['type'] & ClassMetadata::TO_ONE)) {
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<string, mixed[]>
*/
protected function prepareInsertData($entity)
{
return $this->prepareUpdateData($entity, true);
}
/**
* {@inheritDoc}
*/
public function getOwningTable($fieldName)
{
return $this->class->getTableName();
}
/**
* {@inheritDoc}
*/
public function load(array $criteria, $entity = null, $assoc = null, array $hints = [], $lockMode = null, $limit = null, ?array $orderBy = 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, $entity = null)
{
return $this->load($identifier, $entity);
}
/**
* {@inheritDoc}
*/
public function loadOneToOneEntity(array $assoc, $sourceEntity, array $identifier = [])
{
$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'] && ! $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;
}
$sourceClass = $this->em->getClassMetadata($assoc['sourceEntity']);
$owningAssoc = $targetClass->getAssociationMapping($assoc['mappedBy']);
$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, $entity, $lockMode = null)
{
$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]);
}
/**
* {@inheritDoc}
*/
public function count($criteria = [])
{
$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)
{
$orderBy = $criteria->getOrderings();
$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)
{
$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 = array_merge($sqlParams, $this->getValues($value));
$sqlTypes = array_merge($sqlTypes, $this->getTypes($field, $value, $this->class));
}
return [$sqlParams, $sqlTypes];
}
/**
* {@inheritDoc}
*/
public function loadAll(array $criteria = [], ?array $orderBy = null, $limit = null, $offset = null)
{
$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(array $assoc, $sourceEntity, $offset = null, $limit = null)
{
$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.
*
* @param mixed[] $assoc
*
* @return mixed[]
*/
private function loadArrayFromResult(array $assoc, Result $stmt): array
{
$rsm = $this->currentPersisterContext->rsm;
$hints = [UnitOfWork::HINT_DEFEREAGERLOAD => true];
if (isset($assoc['indexBy'])) {
$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.
*
* @param mixed[] $assoc
*
* @return mixed[]
*/
private function loadCollectionFromStatement(
array $assoc,
Result $stmt,
PersistentCollection $coll
): array {
$rsm = $this->currentPersisterContext->rsm;
$hints = [
UnitOfWork::HINT_DEFEREAGERLOAD => true,
'collection' => $coll,
];
if (isset($assoc['indexBy'])) {
$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(array $assoc, $sourceEntity, PersistentCollection $collection)
{
$stmt = $this->getManyToManyStatement($assoc, $sourceEntity);
return $this->loadCollectionFromStatement($assoc, $stmt, $collection);
}
/**
* @param object $sourceEntity
* @psalm-param array<string, mixed> $assoc
*
* @return Result
*
* @throws MappingException
*/
private function getManyToManyStatement(
array $assoc,
$sourceEntity,
?int $offset = null,
?int $limit = null
) {
$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 = $class->associationMappings[$assoc['mappedBy']];
}
$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);
}
/**
* {@inheritDoc}
*/
public function getSelectSQL($criteria, $assoc = null, $lockMode = null, $limit = null, $offset = null, ?array $orderBy = null)
{
$this->switchPersisterContext($offset, $limit);
$lockSql = '';
$joinSql = '';
$orderBySql = '';
if ($assoc !== null && $assoc['type'] === ClassMetadata::MANY_TO_MANY) {
$joinSql = $this->getSelectManyToManyJoinSQL($assoc);
}
if (isset($assoc['orderBy'])) {
$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);
switch ($lockMode) {
case LockMode::PESSIMISTIC_READ:
$lockSql = ' ' . $this->platform->getReadLockSQL();
break;
case LockMode::PESSIMISTIC_WRITE:
$lockSql = ' ' . $this->platform->getWriteLockSQL();
break;
}
$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;
}
/**
* {@inheritDoc}
*/
public function getCountSQL($criteria = [])
{
$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<string, string> $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])) {
if (! $this->class->associationMappings[$fieldName]['isOwningSide']) {
throw InvalidFindByCall::fromInverseSideUsage($this->class->name, $fieldName);
}
$tableAlias = isset($this->class->associationMappings[$fieldName]['inherited'])
? $this->getSQLTableAlias($this->class->associationMappings[$fieldName]['inherited'])
: $baseTableAlias;
foreach ($this->class->associationMappings[$fieldName]['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.
*
* @return string The SQL fragment.
*/
protected function getSelectColumnsSQL()
{
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['type'] & ClassMetadata::TO_ONE && ! $assoc['isOwningSide'];
$isAssocFromOneEager = $assoc['type'] !== ClassMetadata::MANY_TO_MANY && $assoc['fetch'] === ClassMetadata::FETCH_EAGER;
if (! ($isAssocFromOneEager || $isAssocToOneInverseSide)) {
continue;
}
if ((($assoc['type'] & ClassMetadata::TO_MANY) > 0) && $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 (isset($assoc['indexBy'])) {
$this->currentPersisterContext->rsm->addIndexBy($assocAlias, $assoc['indexBy']);
}
if (! $assoc['isOwningSide']) {
$eagerEntity = $this->em->getClassMetadata($assoc['targetEntity']);
$association = $eagerEntity->getAssociationMapping($assoc['mappedBy']);
}
$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.
*
* @param string $field
* @param AssociationMapping $assoc
* @param string $alias
*
* @return string
*/
protected function getSelectColumnAssociationSQL($field, $assoc, ClassMetadata $class, $alias = 'r')
{
if (! ($assoc['isOwningSide'] && $assoc['type'] & ClassMetadata::TO_ONE)) {
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.
*
* @psalm-param AssociationMapping $manyToMany
*
* @return string
*/
protected function getSelectManyToManyJoinSQL(array $manyToMany)
{
$conditions = [];
$association = $manyToMany;
$sourceTableAlias = $this->getSQLTableAlias($this->class->name);
if (! $manyToMany['isOwningSide']) {
$targetEntity = $this->em->getClassMetadata($manyToMany['targetEntity']);
$association = $targetEntity->associationMappings[$manyToMany['mappedBy']];
}
$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);
}
/**
* {@inheritDoc}
*/
public function getInsertSQL()
{
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]]['requireSQLConversion'])
) {
$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.
*
* @return string[] The list of columns.
* @psalm-return list<string>
*/
protected function getInsertColumnList()
{
$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['isOwningSide'] && $assoc['type'] & ClassMetadata::TO_ONE) {
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 string $field The 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.
* @param string $alias
*
* @return string
*/
protected function getSelectColumnSQL($field, ClassMetadata $class, $alias = 'r')
{
$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']);
}
if (isset($fieldMapping['requireSQLConversion'])) {
$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.
*
* @param string $className
* @param string $assocName
*
* @return string The SQL table alias.
*
* @todo Reconsider. Binding table aliases to class names is not such a good idea.
*/
protected function getSQLTableAlias($className, $assocName = '')
{
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)
{
$lockSql = '';
$conditionSql = $this->getSelectConditionSQL($criteria);
switch ($lockMode) {
case LockMode::PESSIMISTIC_READ:
$lockSql = $this->platform->getReadLockSQL();
break;
case LockMode::PESSIMISTIC_WRITE:
$lockSql = $this->platform->getWriteLockSQL();
break;
}
$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.
*
* @param int|null $lockMode One of the Doctrine\DBAL\LockMode::* constants.
* @psalm-param LockMode::*|null $lockMode
*
* @return string
*/
protected function getLockTablesSql($lockMode)
{
if ($lockMode === null) {
Deprecation::trigger(
'doctrine/orm',
'https://github.com/doctrine/orm/pull/9466',
'Passing null as argument to %s is deprecated, pass LockMode::NONE instead.',
__METHOD__
);
$lockMode = LockMode::NONE;
}
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.
*
* @return string
*/
protected function getSelectConditionCriteriaSQL(Criteria $criteria)
{
$expression = $criteria->getWhereExpression();
if ($expression === null) {
return '';
}
$visitor = new SqlExpressionVisitor($this, $this->class);
return $visitor->dispatch($expression);
}
/**
* {@inheritDoc}
*/
public function getSelectConditionStatementSQL($field, $value, $assoc = null, $comparison = null)
{
$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]['requireSQLConversion'])) {
$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.
*
* @psalm-param AssociationMapping|null $assoc
*
* @return string[]
* @psalm-return list<string>
*
* @throws InvalidFindByCall
* @throws UnrecognizedField
*/
private function getSelectConditionStatementColumnSQL(
string $field,
?array $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['type'] === ClassMetadata::MANY_TO_MANY) {
if (! $association['isOwningSide']) {
$association = $assoc;
}
$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
);
}
$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.
*
* @param AssociationMapping|null $assoc
* @psalm-param array<string, mixed> $criteria
* @psalm-param array<string, mixed>|null $assoc
*
* @return string
*/
protected function getSelectConditionSQL(array $criteria, $assoc = null)
{
$conditions = [];
foreach ($criteria as $field => $value) {
$conditions[] = $this->getSelectConditionStatementSQL($field, $value, $assoc);
}
return implode(' AND ', $conditions);
}
/**
* {@inheritDoc}
*/
public function getOneToManyCollection(array $assoc, $sourceEntity, $offset = null, $limit = null)
{
$this->switchPersisterContext($offset, $limit);
$stmt = $this->getOneToManyStatement($assoc, $sourceEntity, $offset, $limit);
return $this->loadArrayFromResult($assoc, $stmt);
}
/**
* {@inheritDoc}
*/
public function loadOneToManyCollection(array $assoc, $sourceEntity, PersistentCollection $collection)
{
$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.
*
* @param object $sourceEntity
* @psalm-param AssociationMapping $assoc
*/
private function getOneToManyStatement(
array $assoc,
$sourceEntity,
?int $offset = null,
?int $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);
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($criteria)
{
$params = [];
$types = [];
foreach ($criteria as $field => $value) {
if ($value === null) {
continue; // skip null values.
}
$types = array_merge($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<int|string|null>}
*/
private function expandToManyParameters(array $criteria): array
{
$params = [];
$types = [];
foreach ($criteria as $criterion) {
if ($criterion['value'] === null) {
continue; // skip null values.
}
$types = array_merge($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.
*
* @param mixed $value
*
* @return int[]|null[]|string[]
* @psalm-return list<int|string|null>
*
* @throws QueryException
*/
private function getTypes(string $field, $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 = $class->associationMappings[$field];
$class = $this->em->getClassMetadata($assoc['targetEntity']);
if (! $assoc['isOwningSide']) {
$assoc = $class->associationMappings[$assoc['mappedBy']];
$class = $this->em->getClassMetadata($assoc['targetEntity']);
}
$columns = $assoc['type'] === ClassMetadata::MANY_TO_MANY
? $assoc['relationToTargetKeyColumns']
: $assoc['sourceToTargetKeyColumns'];
foreach ($columns as $column) {
$types[] = PersisterHelper::getTypeOfColumn($column, $class, $this->em);
}
break;
default:
$types[] = null;
break;
}
if (is_array($value)) {
return array_map(static function ($type) {
$type = Type::getType($type);
return $type->getBindingType() + Connection::ARRAY_PARAM_OFFSET;
}, $types);
}
return $types;
}
/**
* Retrieves the parameters that identifies a value.
*
* @param mixed $value
*
* @return mixed[]
*/
private function getValues($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.
*
* @param mixed $value
*
* @psalm-return list<mixed>
*/
private function getIndividualValue($value): array
{
if (! is_object($value)) {
return [$value];
}
if ($value instanceof BackedEnum) {
return [$value->value];
}
$valueClass = ClassUtils::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)];
}
/**
* {@inheritDoc}
*/
public function exists($entity, ?Criteria $extraConditions = null)
{
$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 = array_merge($params, $criteriaParams);
$types = array_merge($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 array[] $joinColumns The join columns definition of an association.
* @psalm-param array<array<string, mixed>> $joinColumns
*
* @return string LEFT JOIN if one of the columns is nullable, INNER JOIN otherwise.
*/
protected function getJoinSQLForJoinColumns($joinColumns)
{
// 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';
}
/**
* @param string $columnName
*
* @return string
*/
public function getSQLColumnAlias($columnName)
{
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, $targetTableAlias)
{
$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
*
* @param int|null $offset
* @param int|null $limit
*
* @return void
*/
protected function switchPersisterContext($offset, $limit)
{
if ($offset === null && $limit === null) {
$this->currentPersisterContext = $this->noLimitsContext;
return;
}
$this->currentPersisterContext = $this->limitsHandlingContext;
}
/**
* @return string[]
* @psalm-return list<string>
*/
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
);
}
}