<?php
declare(strict_types=1);
/*
* This file is part of the Sonata Project package.
*
* (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Sonata\DoctrineORMAdminBundle\Model;
use Doctrine\Common\Util\ClassUtils;
use Doctrine\DBAL\DBALException;
use Doctrine\DBAL\LockMode;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\UnitOfWork;
use Doctrine\Persistence\ManagerRegistry;
use Sonata\AdminBundle\Admin\FieldDescriptionInterface;
use Sonata\AdminBundle\Datagrid\DatagridInterface;
use Sonata\AdminBundle\Datagrid\ProxyQueryInterface;
use Sonata\AdminBundle\Exception\LockException;
use Sonata\AdminBundle\Exception\ModelManagerException;
use Sonata\AdminBundle\Model\LockInterface;
use Sonata\AdminBundle\Model\ModelManagerInterface;
use Sonata\DoctrineORMAdminBundle\Admin\FieldDescription;
use Sonata\DoctrineORMAdminBundle\Datagrid\OrderByToSelectWalker;
use Sonata\DoctrineORMAdminBundle\Datagrid\ProxyQuery;
use Sonata\Exporter\Source\DoctrineORMQuerySourceIterator;
use Symfony\Component\Form\Exception\PropertyAccessDeniedException;
class ModelManager implements ModelManagerInterface, LockInterface
{
public const ID_SEPARATOR = '~';
/**
* @var ManagerRegistry
*/
protected $registry;
/**
* @var EntityManager[]
*/
protected $cache = [];
public function __construct(ManagerRegistry $registry)
{
$this->registry = $registry;
}
/**
* @param string $class
*
* @return ClassMetadata
*/
public function getMetadata($class)
{
return $this->getEntityManager($class)->getMetadataFactory()->getMetadataFor($class);
}
/**
* Returns the model's metadata holding the fully qualified property, and the last
* property name.
*
* @param string $baseClass The base class of the model holding the fully qualified property
* @param string $propertyFullName The name of the fully qualified property (dot ('.') separated
* property string)
*
* @return array(
* \Doctrine\ORM\Mapping\ClassMetadata $parentMetadata,
* string $lastPropertyName,
* array $parentAssociationMappings
* )
*/
public function getParentMetadataForProperty($baseClass, $propertyFullName)
{
$nameElements = explode('.', $propertyFullName);
$lastPropertyName = array_pop($nameElements);
$class = $baseClass;
$parentAssociationMappings = [];
foreach ($nameElements as $nameElement) {
$metadata = $this->getMetadata($class);
if (isset($metadata->associationMappings[$nameElement])) {
$parentAssociationMappings[] = $metadata->associationMappings[$nameElement];
$class = $metadata->getAssociationTargetClass($nameElement);
continue;
}
break;
}
$properties = \array_slice($nameElements, \count($parentAssociationMappings));
$properties[] = $lastPropertyName;
return [$this->getMetadata($class), implode('.', $properties), $parentAssociationMappings];
}
/**
* @param string $class
*
* @return bool
*/
public function hasMetadata($class)
{
return $this->getEntityManager($class)->getMetadataFactory()->hasMetadataFor($class);
}
public function getNewFieldDescriptionInstance($class, $name, array $options = [])
{
if (!\is_string($name)) {
throw new \RuntimeException('The name argument must be a string');
}
if (!isset($options['route']['name'])) {
$options['route']['name'] = 'edit';
}
if (!isset($options['route']['parameters'])) {
$options['route']['parameters'] = [];
}
list($metadata, $propertyName, $parentAssociationMappings) = $this->getParentMetadataForProperty($class, $name);
$fieldDescription = new FieldDescription();
$fieldDescription->setName($name);
$fieldDescription->setOptions($options);
$fieldDescription->setParentAssociationMappings($parentAssociationMappings);
if (isset($metadata->associationMappings[$propertyName])) {
$fieldDescription->setAssociationMapping($metadata->associationMappings[$propertyName]);
}
if (isset($metadata->fieldMappings[$propertyName])) {
$fieldDescription->setFieldMapping($metadata->fieldMappings[$propertyName]);
}
return $fieldDescription;
}
public function create($object)
{
try {
$entityManager = $this->getEntityManager($object);
$entityManager->persist($object);
$entityManager->flush();
} catch (\PDOException $e) {
throw new ModelManagerException(
sprintf('Failed to create object: %s', ClassUtils::getClass($object)),
$e->getCode(),
$e
);
} catch (DBALException $e) {
throw new ModelManagerException(
sprintf('Failed to create object: %s', ClassUtils::getClass($object)),
$e->getCode(),
$e
);
}
}
public function update($object)
{
try {
$entityManager = $this->getEntityManager($object);
$entityManager->persist($object);
$entityManager->flush();
} catch (\PDOException $e) {
throw new ModelManagerException(
sprintf('Failed to update object: %s', ClassUtils::getClass($object)),
$e->getCode(),
$e
);
} catch (DBALException $e) {
throw new ModelManagerException(
sprintf('Failed to update object: %s', ClassUtils::getClass($object)),
$e->getCode(),
$e
);
}
}
public function delete($object)
{
try {
$entityManager = $this->getEntityManager($object);
$entityManager->remove($object);
$entityManager->flush();
} catch (\PDOException $e) {
throw new ModelManagerException(
sprintf('Failed to delete object: %s', ClassUtils::getClass($object)),
$e->getCode(),
$e
);
} catch (DBALException $e) {
throw new ModelManagerException(
sprintf('Failed to delete object: %s', ClassUtils::getClass($object)),
$e->getCode(),
$e
);
}
}
public function getLockVersion($object)
{
$metadata = $this->getMetadata(ClassUtils::getClass($object));
if (!$metadata->isVersioned) {
return null;
}
return $metadata->reflFields[$metadata->versionField]->getValue($object);
}
public function lock($object, $expectedVersion)
{
$metadata = $this->getMetadata(ClassUtils::getClass($object));
if (!$metadata->isVersioned) {
return;
}
try {
$entityManager = $this->getEntityManager($object);
$entityManager->lock($object, LockMode::OPTIMISTIC, $expectedVersion);
} catch (OptimisticLockException $e) {
throw new LockException($e->getMessage(), $e->getCode(), $e);
}
}
public function find($class, $id)
{
if (!isset($id)) {
return null;
}
$values = array_combine($this->getIdentifierFieldNames($class), explode(self::ID_SEPARATOR, (string) $id));
return $this->getEntityManager($class)->getRepository($class)->find($values);
}
public function findBy($class, array $criteria = [])
{
return $this->getEntityManager($class)->getRepository($class)->findBy($criteria);
}
public function findOneBy($class, array $criteria = [])
{
return $this->getEntityManager($class)->getRepository($class)->findOneBy($criteria);
}
/**
* @param string $class
*
* @return EntityManager
*/
public function getEntityManager($class)
{
if (\is_object($class)) {
$class = \get_class($class);
}
if (!isset($this->cache[$class])) {
$em = $this->registry->getManagerForClass($class);
if (!$em) {
throw new \RuntimeException(sprintf('No entity manager defined for class %s', $class));
}
$this->cache[$class] = $em;
}
return $this->cache[$class];
}
public function getParentFieldDescription($parentAssociationMapping, $class)
{
$fieldName = $parentAssociationMapping['fieldName'];
$metadata = $this->getMetadata($class);
$associatingMapping = $metadata->associationMappings[$parentAssociationMapping];
$fieldDescription = $this->getNewFieldDescriptionInstance($class, $fieldName);
$fieldDescription->setName($parentAssociationMapping);
$fieldDescription->setAssociationMapping($associatingMapping);
return $fieldDescription;
}
public function createQuery($class, $alias = 'o')
{
$repository = $this->getEntityManager($class)->getRepository($class);
return new ProxyQuery($repository->createQueryBuilder($alias));
}
public function executeQuery($query)
{
if ($query instanceof QueryBuilder) {
return $query->getQuery()->execute();
}
return $query->execute();
}
/**
* NEXT_MAJOR: Remove this function.
*
* @deprecated since sonata-project/doctrine-orm-admin-bundle 3.x. To be removed in 4.0.
*/
public function getModelIdentifier($class)
{
return $this->getMetadata($class)->identifier;
}
public function getIdentifierValues($entity)
{
// Fix code has an impact on performance, so disable it ...
//$entityManager = $this->getEntityManager($entity);
//if (!$entityManager->getUnitOfWork()->isInIdentityMap($entity)) {
// throw new \RuntimeException('Entities passed to the choice field must be managed');
//}
$class = ClassUtils::getClass($entity);
$metadata = $this->getMetadata($class);
$platform = $this->getEntityManager($class)->getConnection()->getDatabasePlatform();
$identifiers = [];
foreach ($metadata->getIdentifierValues($entity) as $name => $value) {
if (!\is_object($value)) {
$identifiers[] = $value;
continue;
}
$fieldType = $metadata->getTypeOfField($name);
$type = $fieldType && Type::hasType($fieldType) ? Type::getType($fieldType) : null;
if ($type) {
$identifiers[] = $this->getValueFromType($value, $type, $fieldType, $platform);
continue;
}
$identifierMetadata = $this->getMetadata(ClassUtils::getClass($value));
foreach ($identifierMetadata->getIdentifierValues($value) as $value) {
$identifiers[] = $value;
}
}
return $identifiers;
}
public function getIdentifierFieldNames($class)
{
return $this->getMetadata($class)->getIdentifierFieldNames();
}
public function getNormalizedIdentifier($entity)
{
if (null === $entity) {
return null;
}
if (!\is_object($entity)) {
throw new \RuntimeException('Invalid argument, object or null required');
}
if (\in_array($this->getEntityManager($entity)->getUnitOfWork()->getEntityState($entity), [
UnitOfWork::STATE_NEW,
UnitOfWork::STATE_REMOVED,
], true)) {
return null;
}
$values = $this->getIdentifierValues($entity);
if (0 === \count($values)) {
return null;
}
return implode(self::ID_SEPARATOR, $values);
}
/**
* {@inheritdoc}
*
* The ORM implementation does nothing special but you still should use
* this method when using the id in a URL to allow for future improvements.
*/
public function getUrlSafeIdentifier($entity)
{
return $this->getNormalizedIdentifier($entity);
}
public function addIdentifiersToQuery($class, ProxyQueryInterface $queryProxy, array $idx)
{
$fieldNames = $this->getIdentifierFieldNames($class);
$qb = $queryProxy->getQueryBuilder();
$prefix = uniqid();
$sqls = [];
foreach ($idx as $pos => $id) {
$ids = explode(self::ID_SEPARATOR, $id);
$ands = [];
foreach ($fieldNames as $posName => $name) {
$parameterName = sprintf('field_%s_%s_%d', $prefix, $name, $pos);
$ands[] = sprintf('%s.%s = :%s', current($qb->getRootAliases()), $name, $parameterName);
$qb->setParameter($parameterName, $ids[$posName]);
}
$sqls[] = implode(' AND ', $ands);
}
$qb->andWhere(sprintf('( %s )', implode(' OR ', $sqls)));
}
public function batchDelete($class, ProxyQueryInterface $queryProxy)
{
$queryProxy->select('DISTINCT '.current($queryProxy->getRootAliases()));
try {
$entityManager = $this->getEntityManager($class);
$i = 0;
foreach ($queryProxy->getQuery()->iterate() as $pos => $object) {
$entityManager->remove($object[0]);
if (0 === (++$i % 20)) {
$entityManager->flush();
$entityManager->clear();
}
}
$entityManager->flush();
$entityManager->clear();
} catch (\PDOException $e) {
throw new ModelManagerException('', 0, $e);
} catch (DBALException $e) {
throw new ModelManagerException('', 0, $e);
}
}
public function getDataSourceIterator(DatagridInterface $datagrid, array $fields, $firstResult = null, $maxResult = null)
{
$datagrid->buildPager();
$query = $datagrid->getQuery();
$query->select('DISTINCT '.current($query->getRootAliases()));
$query->setFirstResult($firstResult);
$query->setMaxResults($maxResult);
if ($query instanceof ProxyQueryInterface) {
$sortBy = $query->getSortBy();
if (!empty($sortBy)) {
$query->addOrderBy($sortBy, $query->getSortOrder());
$query = $query->getQuery();
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [OrderByToSelectWalker::class]);
} else {
$query = $query->getQuery();
}
}
return new DoctrineORMQuerySourceIterator($query, $fields);
}
public function getExportFields($class)
{
$metadata = $this->getEntityManager($class)->getClassMetadata($class);
return $metadata->getFieldNames();
}
public function getModelInstance($class)
{
$r = new \ReflectionClass($class);
if ($r->isAbstract()) {
throw new \RuntimeException(sprintf('Cannot initialize abstract class: %s', $class));
}
$constructor = $r->getConstructor();
if (null !== $constructor && (!$constructor->isPublic() || $constructor->getNumberOfRequiredParameters() > 0)) {
return $r->newInstanceWithoutConstructor();
}
return new $class();
}
public function getSortParameters(FieldDescriptionInterface $fieldDescription, DatagridInterface $datagrid)
{
$values = $datagrid->getValues();
if ($this->isFieldAlreadySorted($fieldDescription, $datagrid)) {
if ('ASC' === $values['_sort_order']) {
$values['_sort_order'] = 'DESC';
} else {
$values['_sort_order'] = 'ASC';
}
} else {
$values['_sort_order'] = 'ASC';
}
$values['_sort_by'] = \is_string($fieldDescription->getOption('sortable')) ? $fieldDescription->getOption('sortable') : $fieldDescription->getName();
return ['filter' => $values];
}
public function getPaginationParameters(DatagridInterface $datagrid, $page)
{
$values = $datagrid->getValues();
if (isset($values['_sort_by']) && $values['_sort_by'] instanceof FieldDescriptionInterface) {
$values['_sort_by'] = $values['_sort_by']->getName();
}
$values['_page'] = $page;
return ['filter' => $values];
}
public function getDefaultSortValues($class)
{
return [
'_sort_order' => 'ASC',
'_sort_by' => implode(',', $this->getModelIdentifier($class)),
'_page' => 1,
'_per_page' => 25,
];
}
public function modelTransform($class, $instance)
{
return $instance;
}
public function modelReverseTransform($class, array $array = [])
{
$instance = $this->getModelInstance($class);
$metadata = $this->getMetadata($class);
$reflClass = $metadata->reflClass;
foreach ($array as $name => $value) {
$reflection_property = false;
// property or association ?
if (\array_key_exists($name, $metadata->fieldMappings)) {
$property = $metadata->fieldMappings[$name]['fieldName'];
$reflection_property = $metadata->reflFields[$name];
} elseif (\array_key_exists($name, $metadata->associationMappings)) {
$property = $metadata->associationMappings[$name]['fieldName'];
} else {
$property = $name;
}
$setter = 'set'.$this->camelize($name);
if ($reflClass->hasMethod($setter)) {
if (!$reflClass->getMethod($setter)->isPublic()) {
throw new PropertyAccessDeniedException(sprintf(
'Method "%s()" is not public in class "%s"',
$setter,
$reflClass->getName()
));
}
$instance->$setter($value);
} elseif ($reflClass->hasMethod('__set')) {
// needed to support magic method __set
$instance->$property = $value;
} elseif ($reflClass->hasProperty($property)) {
if (!$reflClass->getProperty($property)->isPublic()) {
throw new PropertyAccessDeniedException(sprintf(
'Property "%s" is not public in class "%s". Maybe you should create the method "set%s()"?',
$property,
$reflClass->getName(),
ucfirst($property)
));
}
$instance->$property = $value;
} elseif ($reflection_property) {
$reflection_property->setValue($instance, $value);
}
}
return $instance;
}
public function getModelCollectionInstance($class)
{
return new \Doctrine\Common\Collections\ArrayCollection();
}
public function collectionClear(&$collection)
{
return $collection->clear();
}
public function collectionHasElement(&$collection, &$element)
{
return $collection->contains($element);
}
public function collectionAddElement(&$collection, &$element)
{
return $collection->add($element);
}
public function collectionRemoveElement(&$collection, &$element)
{
return $collection->removeElement($element);
}
/**
* method taken from Symfony\Component\PropertyAccess\PropertyAccessor.
*
* @param string $property
*
* @return mixed
*/
protected function camelize($property)
{
return str_replace(' ', '', ucwords(str_replace('_', ' ', $property)));
}
private function isFieldAlreadySorted(FieldDescriptionInterface $fieldDescription, DatagridInterface $datagrid): bool
{
$values = $datagrid->getValues();
if (!isset($values['_sort_by']) || !$values['_sort_by'] instanceof FieldDescriptionInterface) {
return false;
}
return $values['_sort_by']->getName() === $fieldDescription->getName()
|| $values['_sort_by']->getName() === $fieldDescription->getOption('sortable');
}
/**
* @param mixed $value
*/
private function getValueFromType($value, Type $type, string $fieldType, AbstractPlatform $platform): string
{
if ($platform->hasDoctrineTypeMappingFor($fieldType) &&
'binary' === $platform->getDoctrineTypeMapping($fieldType)
) {
return (string) $type->convertToPHPValue($value, $platform);
}
// some libraries may have `toString()` implementation
if (\is_callable([$value, 'toString'])) {
return $value->toString();
}
// final fallback to magic `__toString()` which may throw an exception in 7.4
if (method_exists($value, '__toString')) {
return $value->__toString();
}
return (string) $type->convertToDatabaseValue($value, $platform);
}
}