edmondscommerce/doctrine-static-meta

View on GitHub
src/Entity/Factory/EntityFactory.php

Summary

Maintainability
C
1 day
Test Coverage
<?php

declare(strict_types=1);

namespace EdmondsCommerce\DoctrineStaticMeta\Entity\Factory;

use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\NotifyPropertyChanged;
use EdmondsCommerce\DoctrineStaticMeta\CodeGeneration\NamespaceHelper;
use EdmondsCommerce\DoctrineStaticMeta\DoctrineStaticMeta;
use EdmondsCommerce\DoctrineStaticMeta\Entity\DataTransferObjects\DtoFactory;
use EdmondsCommerce\DoctrineStaticMeta\Entity\Fields\Interfaces\PrimaryKey\IdFieldInterface;
use EdmondsCommerce\DoctrineStaticMeta\Entity\Interfaces\AlwaysValidInterface;
use EdmondsCommerce\DoctrineStaticMeta\Entity\Interfaces\DataTransferObjectInterface;
use EdmondsCommerce\DoctrineStaticMeta\Entity\Interfaces\EntityInterface;
use EdmondsCommerce\DoctrineStaticMeta\Entity\Interfaces\UsesPHPMetaDataInterface;
use EdmondsCommerce\DoctrineStaticMeta\Exception\MultipleValidationException;
use EdmondsCommerce\DoctrineStaticMeta\Exception\ValidationException;
use InvalidArgumentException;
use LogicException;
use RuntimeException;
use ts\Reflection\ReflectionClass;
use TypeError;

use function get_class;
use function is_object;
use function print_r;
use function spl_object_hash;

/**
 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 */
class EntityFactory implements EntityFactoryInterface
{
    /**
     * This array is used to track Entities that in the process of being created as part of a transaction
     *
     * @var EntityInterface[][]
     */
    private static $created = [];
    /**
     * @var NamespaceHelper
     */
    protected $namespaceHelper;
    /**
     * @var EntityDependencyInjector
     */
    protected $entityDependencyInjector;
    /**
     * @var EntityManagerInterface
     */
    private $entityManager;
    /**
     * @var DtoFactory
     */
    private $dtoFactory;
    /**
     * @var array|bool[]
     */
    private $dtosProcessed;

    public function __construct(
        NamespaceHelper $namespaceHelper,
        EntityDependencyInjector $entityDependencyInjector,
        DtoFactory $dtoFactory
    ) {
        $this->namespaceHelper          = $namespaceHelper;
        $this->entityDependencyInjector = $entityDependencyInjector;
        $this->dtoFactory               = $dtoFactory;
    }

    public function setEntityManager(EntityManagerInterface $entityManager): EntityFactoryInterface
    {
        $this->entityManager = $entityManager;

        return $this;
    }

    /**
     * Get an instance of the specific Entity Factory for a specified Entity
     *
     * Not type hinting the return because the whole point of this is to have an entity specific method, which we
     * can't hint for
     *
     * @param string $entityFqn
     *
     * @return mixed
     */
    public function createFactoryForEntity(string $entityFqn)
    {
        $this->assertEntityManagerSet();
        $factoryFqn = $this->namespaceHelper->getFactoryFqnFromEntityFqn($entityFqn);

        return new $factoryFqn($this, $this->entityManager);
    }

    private function assertEntityManagerSet(): void
    {
        if (!$this->entityManager instanceof EntityManagerInterface) {
            throw new RuntimeException(
                'No EntityManager set, this must be set first using setEntityManager()'
            );
        }
    }

    public function getEntity(string $className)
    {
        return $this->create($className);
    }

    /**
     * Build a new entity, optionally pass in a DTO to provide the data that should be used
     *
     * Optionally pass in an array of property=>value
     *
     * @param string                           $entityFqn
     *
     * @param DataTransferObjectInterface|null $dto
     *
     * @return mixed
     * @throws MultipleValidationException
     * @throws ValidationException
     */
    public function create(string $entityFqn, DataTransferObjectInterface $dto = null)
    {
        $this->assertEntityManagerSet();

        return $this->createEntity($entityFqn, $dto);
    }

    /**
     * Create the Entity
     *
     * If the update step throw an exception, then we detach the entity to prevent us having an empty entity in the
     * unit of work which would otherwise be saved to the DB
     *
     * @param string                           $entityFqn
     *
     * @param DataTransferObjectInterface|null $dto
     *
     * @param bool                             $isRootEntity
     *
     * @return EntityInterface
     * @throws MultipleValidationException
     * @throws ValidationException
     * @SuppressWarnings(PHPMD.BooleanArgumentFlag)
     */
    private function createEntity(
        string $entityFqn,
        DataTransferObjectInterface $dto = null,
        $isRootEntity = true
    ): EntityInterface {
        if ($isRootEntity) {
            $this->dtosProcessed = [];
        }
        if (null === $dto) {
            $dto = $this->dtoFactory->createEmptyDtoFromEntityFqn($entityFqn);
        }
        $idString = (string)$dto->getId();
        if (isset(self::$created[$entityFqn][$idString])) {
            return self::$created[$entityFqn][$idString];
        }
        try {
            #At this point a new entity is added to the unit of work
            $entity = $this->getNewInstance($entityFqn, $dto->getId());

            self::$created[$entityFqn][$idString] = $entity;

            #At this point, nested entities are added to the unit of work
            $this->updateDto($entity, $dto);
            #At this point, the entity values are set
            $entity->update($dto);

            if ($isRootEntity) {
                #Now we have persisted all the entities, we need to validate them all
                $this->stopTransaction();
            }
        } catch (ValidationException | MultipleValidationException | TypeError $e) {
            # Something has gone wrong, now we need to remove all created entities from the unit of work
            foreach (self::$created as $entities) {
                foreach ($entities as $createdEntity) {
                    if ($createdEntity instanceof EntityInterface) {
                        $this->entityManager->getUnitOfWork()->detach($createdEntity);
                    }
                }
            }
            # And then we need to ensure that they are cleared out from the created and processed arrays
            self::$created       = [];
            $this->dtosProcessed = [];
            throw $e;
        }

        return $entity;
    }

    /**
     * Build a new instance, bypassing PPP protections so that we can call private methods and set the private
     * transaction property
     *
     * @param string $entityFqn
     * @param mixed  $id
     *
     * @return EntityInterface
     */
    private function getNewInstance(string $entityFqn, $id): EntityInterface
    {
        if (isset(self::$created[$entityFqn][(string)$id])) {
            throw new RuntimeException('Trying to get a new instance when one has already been created for this ID');
        }
        $reflection = $this->getDoctrineStaticMetaForEntityFqn($entityFqn)
                           ->getReflectionClass();
        $entity     = $reflection->newInstanceWithoutConstructor();

        $runInit = $reflection->getMethod(UsesPHPMetaDataInterface::METHOD_RUN_INIT);
        $runInit->setAccessible(true);
        $runInit->invoke($entity);

        $transactionProperty = $reflection->getProperty(AlwaysValidInterface::CREATION_TRANSACTION_RUNNING_PROPERTY);
        $transactionProperty->setAccessible(true);
        $transactionProperty->setValue($entity, true);

        $idSetter = $reflection->getMethod('set' . IdFieldInterface::PROP_ID);
        $idSetter->setAccessible(true);
        $idSetter->invoke($entity, $id);

        if ($entity instanceof EntityInterface) {
            $this->initialiseEntity($entity);

            $this->entityManager->persist($entity);

            return $entity;
        }
        throw new LogicException('Failed to create an instance of EntityInterface');
    }

    private function getDoctrineStaticMetaForEntityFqn(string $entityFqn): DoctrineStaticMeta
    {
        return $entityFqn::getDoctrineStaticMeta();
    }

    /**
     * Take an already instantiated Entity and perform the final initialisation steps
     *
     * @param EntityInterface $entity
     *
     * @throws \ReflectionException
     */
    public function initialiseEntity(EntityInterface $entity): void
    {
        $entity->ensureMetaDataIsSet($this->entityManager);
        $this->addListenerToEntityIfRequired($entity);
        $this->entityDependencyInjector->injectEntityDependencies($entity);
        $debugInitMethod = $entity::getDoctrineStaticMeta()
                                  ->getReflectionClass()
                                  ->getMethod(UsesPHPMetaDataInterface::METHOD_DEBUG_INIT);
        $debugInitMethod->setAccessible(true);
        $debugInitMethod->invoke($entity);
    }

    /**
     * Generally DSM Entities are using the Notify change tracking policy.
     * This ensures that they are fully set up for that
     *
     * @param EntityInterface $entity
     */
    private function addListenerToEntityIfRequired(EntityInterface $entity): void
    {
        if (!$entity instanceof NotifyPropertyChanged) {
            return;
        }
        $listener = $this->entityManager->getUnitOfWork();
        $entity->addPropertyChangedListener($listener);
    }

    private function updateDto(
        EntityInterface $entity,
        DataTransferObjectInterface $dto
    ): void {
        $this->replaceNestedDtoWithEntityInstanceIfIdsMatch($dto, $entity);
        $this->replaceNestedDtosWithNewEntities($dto);
        $this->dtosProcessed[spl_object_hash($dto)] = true;
    }

    /**
     * @param DataTransferObjectInterface $dto
     * @param EntityInterface             $entity
     * @SuppressWarnings(PHPMD.NPathComplexity)
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     */
    private function replaceNestedDtoWithEntityInstanceIfIdsMatch(
        DataTransferObjectInterface $dto,
        EntityInterface $entity
    ): void {
        $dtoHash = spl_object_hash($dto);
        if (isset($this->dtosProcessed[$dtoHash])) {
            return;
        }
        $this->dtosProcessed[$dtoHash] = true;
        $getters                       = $this->getGettersForDtosOrCollections($dto);
        if ([[], []] === $getters) {
            return;
        }
        [$dtoGetters, $collectionGetters] = array_values($getters);
        $entityFqn = get_class($entity);
        foreach ($dtoGetters as $getter) {
            $propertyName        = substr($getter, 3, -3);
            $issetAsEntityMethod = 'isset' . $propertyName . 'AsEntity';
            if (true === $dto->$issetAsEntityMethod()) {
                continue;
            }

            $got = $dto->$getter();
            if (null === $got) {
                continue;
            }
            $gotHash = spl_object_hash($got);
            if (isset($this->dtosProcessed[$gotHash])) {
                continue;
            }

            if ($got instanceof DataTransferObjectInterface) {
                if ($got::getEntityFqn() === $entityFqn && $got->getId() === $entity->getId()) {
                    $setter = 'set' . $propertyName;
                    $dto->$setter($entity);
                    continue;
                }
                $this->replaceNestedDtoWithEntityInstanceIfIdsMatch($got, $entity);
                continue;
            }

            throw new LogicException('Unexpected got item ' . get_class($got));
        }
        foreach ($collectionGetters as $getter) {
            $got = $dto->$getter();
            if (false === ($got instanceof Collection)) {
                continue;
            }
            foreach ($got as $key => $gotItem) {
                if (false === ($gotItem instanceof DataTransferObjectInterface)) {
                    continue;
                }
                if ($gotItem::getEntityFqn() === $entityFqn && $gotItem->getId() === $entity->getId()) {
                    $got->set($key, $entity);
                    continue;
                }
                $this->replaceNestedDtoWithEntityInstanceIfIdsMatch($gotItem, $entity);
            }
        }
    }

    private function getGettersForDtosOrCollections(DataTransferObjectInterface $dto): array
    {
        $dtoReflection     = new ReflectionClass(get_class($dto));
        $dtoGetters        = [];
        $collectionGetters = [];
        foreach ($dtoReflection->getMethods() as $method) {
            $methodName = $method->getName();
            if (0 !== strpos($methodName, 'get')) {
                continue;
            }
            $returnType = $method->getReturnType();
            if (null === $returnType) {
                continue;
            }
            $returnTypeName = $returnType->getName();
            if (false === \ts\stringContains($returnTypeName, '\\')) {
                continue;
            }
            $returnTypeReflection = new ReflectionClass($returnTypeName);

            if ($returnTypeReflection->implementsInterface(DataTransferObjectInterface::class)) {
                $dtoGetters[] = $methodName;
                continue;
            }
            if ($returnTypeReflection->implementsInterface(Collection::class)) {
                $collectionGetters[] = $methodName;
                continue;
            }
        }

        return [$dtoGetters, $collectionGetters];
    }

    /**
     * @param DataTransferObjectInterface $dto
     *
     * @throws MultipleValidationException
     * @throws ValidationException
     * @SuppressWarnings(PHPMD.NPathComplexity)
     */
    private function replaceNestedDtosWithNewEntities(DataTransferObjectInterface $dto): void
    {
        $getters = $this->getGettersForDtosOrCollections($dto);
        if ([[], []] === $getters) {
            return;
        }
        [$dtoGetters, $collectionGetters] = array_values($getters);
        foreach ($dtoGetters as $getter) {
            $propertyName        = substr($getter, 3, -3);
            $issetAsEntityMethod = 'isset' . $propertyName . 'AsEntity';
            if (true === $dto->$issetAsEntityMethod()) {
                continue;
            }

            $nestedDto = $dto->$getter();
            if (null === $nestedDto) {
                continue;
            }
            $setter = 'set' . substr($getter, 3, -3);
            $dto->$setter($this->createEntity($nestedDto::getEntityFqn(), $nestedDto, false));
        }
        foreach ($collectionGetters as $getter) {
            $nestedDto = $dto->$getter();
            if (false === ($nestedDto instanceof Collection)) {
                continue;
            }
            $this->convertCollectionOfDtosToEntities($nestedDto);
        }
    }

    /**
     * This will take an ArrayCollection of DTO objects and replace them with the Entities
     *
     * @param Collection $collection
     *
     * @throws MultipleValidationException
     * @throws ValidationException
     */
    private function convertCollectionOfDtosToEntities(Collection $collection): void
    {
        if (0 === $collection->count()) {
            return;
        }
        [$dtoFqn, $collectionEntityFqn] = $this->deriveDtoAndEntityFqnFromCollection($collection);

        foreach ($collection as $key => $dto) {
            if ($dto instanceof $collectionEntityFqn) {
                continue;
            }
            if (false === is_object($dto)) {
                throw new InvalidArgumentException('Unexpected DTO value ' .
                                                    print_r($dto, true) .
                                                    ', expected an instance of' .
                                                    $dtoFqn);
            }
            if (false === ($dto instanceof DataTransferObjectInterface)) {
                throw new InvalidArgumentException('Found none DTO item in collection, was instance of ' .
                                                    get_class($dto));
            }
            if (false === ($dto instanceof $dtoFqn)) {
                throw new InvalidArgumentException('Unexpected DTO ' . get_class($dto) . ', expected ' . $dtoFqn);
            }
            $collection->set($key, $this->createEntity($collectionEntityFqn, $dto, false));
        }
    }

    /**
     * Loop through a collection and determine the DTO and Entity Fqn it contains
     *
     * @param Collection $collection
     *
     * @return array
     * @SuppressWarnings(PHPMD.NPathComplexity)
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     */
    private function deriveDtoAndEntityFqnFromCollection(Collection $collection): array
    {
        if (0 === $collection->count()) {
            throw new RuntimeException('Collection is empty');
        }
        $dtoFqn              = null;
        $collectionEntityFqn = null;
        foreach ($collection as $dto) {
            if ($dto instanceof EntityInterface) {
                $collectionEntityFqn = get_class($dto);
                continue;
            }
            if (false === ($dto instanceof DataTransferObjectInterface)) {
                throw new InvalidArgumentException(
                    'Found none DTO item in collection, was instance of ' . get_class($dto)
                );
            }
            if (null === $dtoFqn) {
                $dtoFqn = get_class($dto);
                continue;
            }
            if (false === ($dto instanceof $dtoFqn)) {
                throw new InvalidArgumentException(
                    'Mismatched collection, expecting dtoFqn ' .
                    $dtoFqn .
                    ' but found ' .
                    get_class($dto)
                );
            }
        }
        if (null === $dtoFqn && null === $collectionEntityFqn) {
            throw new RuntimeException('Failed deriving either the DTO or Entity FQN from the collection');
        }
        if (null === $collectionEntityFqn) {
            $collectionEntityFqn = $this->namespaceHelper->getEntityFqnFromEntityDtoFqn($dtoFqn);
        }
        if (null === $dtoFqn) {
            $dtoFqn = $this->namespaceHelper->getEntityDtoFqnFromEntityFqn($collectionEntityFqn);
        }

        return [$dtoFqn, $collectionEntityFqn];
    }

    /**
     * Loop through all created entities and reset the transaction running property to false,
     * then remove the list of created entities
     *
     * @throws MultipleValidationException
     */
    private function stopTransaction(): void
    {
        $validationExceptions = [];
        foreach (self::$created as $entities) {
            foreach ($entities as $entity) {
                $transactionProperty =
                    $entity::getDoctrineStaticMeta()
                           ->getReflectionClass()
                           ->getProperty(AlwaysValidInterface::CREATION_TRANSACTION_RUNNING_PROPERTY);
                $transactionProperty->setAccessible(true);
                $transactionProperty->setValue($entity, false);
                try {
                    $entity->getValidator()->validate();
                } catch (ValidationException $validationException) {
                    $validationExceptions[] = $validationException;
                    continue;
                }
            }
        }
        if ([] !== $validationExceptions) {
            throw new MultipleValidationException($validationExceptions);
        }
        self::$created       = [];
        $this->dtosProcessed = [];
    }
}