Skip to content

Instantly share code, notes, and snippets.

@danieledangeli
Created July 15, 2014 11:30
Show Gist options
  • Save danieledangeli/0dc96c4d86730fb59999 to your computer and use it in GitHub Desktop.
Save danieledangeli/0dc96c4d86730fb59999 to your computer and use it in GitHub Desktop.
Clone a doctrine entity
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Inflector\Inflector;
use Doctrine\Common\Persistence\Mapping\AbstractClassMetadataFactory;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\ClassMetadataInfo;
use Doctrine\ORM\Mapping\MappingException;
use Doctrine\ORM\PersistentCollection;
use Foodity\CoreBundle\Helper\EntityHelper;
/**
* Class CloneableService
* This class is a service to clone a doctrine entity with all the associations.
* The goal is to explore the associations inside an entity and recursively
* clone the entity if needed.
*
* Example: A has many B objects and many to many C objects and one D object ( the relation is Many to One )
* A -> B, A <-> C, D -> A
*
* So clone(A) = Ac must be an entity with this characteristics:
* Ac-> Bc, Ac <-> C, D-> Ac
* We need to clone the entity B ( because the foreign key of the association is stored on the B entity ).
* So we need to apply a recursion over the B entity to obtain Bc entity.
* Obviously if B has a relation to others entities the algorithm still works over
* B's association entities until the exit condition, that is when there aren't other entities to be explored ).
*
* We need only to update the doctrine metadata over the many to many association. The foreign keys are stored
* by doctrine in a split table. So we need just to add a reference of C over the Ac cloned object.
* The Many to one case is not processed ( we clone the base entity A with reflection, so the the reference of D in Ac
* is cloned correctly).
* The one to one case is similar to the one to many case ( with very little bit differences )
*
* @package Foodity\CoreBundle\Service\Cloneable
*/
class CloneableService
{
const CLASS_LABEL = 'class';
const PROPERTY_NAME_LABEL = 'property';
/** @var AbstractClassMetadataFactory */
protected $metadataFactory;
/** @var EntityManager */
protected $em;
/**
* @var EntityHelper
*/
private $entityHelper;
/**
* @param EntityManager $entityManager
* @param EntityHelper $entityHelper
*/
public function __construct(EntityManager $entityManager, EntityHelper $entityHelper)
{
$this->em = $entityManager;
$this->metadataFactory = $this->em->getMetadataFactory();
$this->entityHelper = $entityHelper;
}
/**
* @param mixed $entity
* @param array $exclusionMap
*
* @return Object cloned entity
*/
public function doClone($entity, array $exclusionMap = array())
{
if (!$this->entityHelper->isEntity($entity)) {
throw new \InvalidArgumentException(sprintf('Argument 1 of %s() is not an Entity.', __METHOD__));
}
$className = $this->entityHelper->getEntityClassName($entity);
$associationMap = array();
$this->exploreAssociation(array($className), array(), $associationMap);
$startClone = $this->applyReflection($entity, $className);
$explored = array();
$clonedEntity = $this->exploreMap($associationMap, $startClone, $explored, $exclusionMap);
return $clonedEntity;
}
/**
* @param string $className
* @param string $field
*
* @return mixed
*/
public function getInverseAssociation($className, $field)
{
$associationMapping = $this->getAssociationMapping($className, $field);
return $associationMapping['mappedBy'];
}
/**
* @param string $className
* @param string $field
*
* @return bool
*/
public function isManyToManyAssociation($className, $field)
{
return $this->getAssociationType($className, $field) == ClassMetadataInfo::MANY_TO_MANY;
}
/**
* @param string $className
* @param string $field
*
* @return bool
*/
public function isManyToOneAssociation($className, $field)
{
return $this->getAssociationType($className, $field) == ClassMetadataInfo::MANY_TO_ONE;
}
public function getAssociationType($className, $field)
{
$associationMapping = $this->getAssociationMapping($className, $field);
return $associationMapping['type'];
}
/**
* @param string $className
* @param string $fieldName
*
* @return bool
*/
public function isUniqueField($className, $fieldName)
{
/** @var ClassMetadata $class */
$class = $this->metadataFactory->getMetadataFor($className);
try {
return $class->isUniqueField($fieldName);
} catch (MappingException $e) {
return false;
}
}
/**
* @param array $associationNames
* @param array $explored
* @param array $mapping
*/
public function exploreAssociation(array $associationNames, array $explored, array &$mapping)
{
$found = false;
if (count($associationNames) === 0) {
return;
}
$toExplore = null;
while (!$found) {
$toExplore = array_shift($associationNames);
if (!in_array($toExplore, $explored)) {
$explored[] = $toExplore;
$found = true;
} elseif (count($associationNames) === 0) {
return;
}
}
$class = $this->metadataFactory->getMetadataFor($toExplore);
$newAssociationNames = $class->getAssociationNames();
foreach ($newAssociationNames as $associationName) {
$className = $class->getAssociationTargetClass($associationName);
if (!$this->isManyToOneAssociation($class->getName(), $associationName)) {
$associationNames[] = $className;
if (!array_key_exists($toExplore, $mapping)) {
$mapping[$toExplore] = array();
}
$mapping[$toExplore][] = array(
self::PROPERTY_NAME_LABEL => $associationName,
self::CLASS_LABEL => $className
);
}
}
$this->exploreAssociation($associationNames, $explored, $mapping);
}
/**
* Build a reflection for the cloned object
*
* @param mixed $object
* @param string $className
*
* @return mixed
*/
protected function applyReflection($object, $className)
{
$oldEntity = $object;
$newEntity = new $className();
$oldReflection = new \ReflectionObject($oldEntity);
$newReflection = new \ReflectionObject($newEntity);
foreach ($oldReflection->getProperties() as $property) {
if ($newReflection->hasProperty($property->getName()) && $property->getName() != 'id') {
$newProperty = $newReflection->getProperty($property->getName());
$newProperty->setAccessible(true);
$propertyName = $property->getName();
$accessor = $this->getAccessorFromPropertyName($propertyName, $oldEntity);
$oldValue = $oldEntity->$accessor();
if (!$this->isUniqueField($className, $propertyName)) {
$newProperty->setValue($newEntity, $oldValue);
}
}
}
return $newEntity;
}
/**
* @param string $className
* @param string $field
*
* @return array
* @throws MappingException
*/
private function getAssociationMapping($className, $field)
{
/** @var ClassMetadata $class */
$class = $this->metadataFactory->getMetadataFor($className);
return $class->getAssociationMapping($field);
}
/**
* @param string $className
* @param string $field
*
* @return bool
*/
private function hasAssociation($className, $field)
{
$class = $this->metadataFactory->getMetadataFor($className);
return $class->hasAssociation($field);
}
/**
* @param array $map
* @param Object $father
* @param array $alreadyProcessed
* @param array $exclusionMap
* @param string|null $callerClassName
*
* @return Object
*/
public function exploreMap($map, $father, &$alreadyProcessed, $exclusionMap, $callerClassName = null)
{
$class = $this->metadataFactory->getMetadataFor($this->entityHelper->getEntityClassName($father));
$key = $class->getName();
//exit condition
if (count($alreadyProcessed) === count($map)) {
return $father;
}
//not need to explore
if (!array_key_exists($key, $map)) {
return $father;
}
$mapEntry = $map[$key];
//map Entry to explore
foreach ($mapEntry as $entry) {
$field = $entry[self::PROPERTY_NAME_LABEL];
$sonClassName = $entry[self::CLASS_LABEL];
$inverseField = $this->getInverseAssociation($class->getName(), $field);
$associationType = $this->getAssociationType($key, $field);
switch ($associationType) {
case ClassMetadataInfo::ONE_TO_MANY:
$father = $this->oneToManyStrategy(
$father,
$key,
$field,
$sonClassName,
$inverseField,
$map,
$alreadyProcessed,
$exclusionMap
);
break;
case ClassMetadataInfo::ONE_TO_ONE:
$father = $this->oneToOneStrategy(
$father,
$key,
$field,
$sonClassName,
$inverseField,
$map,
$alreadyProcessed,
$exclusionMap,
$callerClassName
);
break;
case ClassMetadataInfo::MANY_TO_MANY:
$father = $this->manyToManyStrategy($father, $key, $field, $sonClassName, $exclusionMap);
break;
}
$alreadyProcessed[$key] = $father;
}
return $father;
}
/**
* @param Object $father
* @param string $fatherClassName
* @param string $field
* @param string $sonClassName
* @param string $inverseField
* @param array $map
* @param array $alreadyProcessed
* @param array $exclusionMap
*
* @return Object
*/
private function oneToManyStrategy(
$father,
$fatherClassName,
$field,
$sonClassName,
$inverseField,
$map,
&$alreadyProcessed,
$exclusionMap
) {
$setAccessorSonToFather = $this->setAccessorFromPropertyName($inverseField);
$getAccessorFatherToSon = $this->getAccessorFromPropertyName($field, $father);
$setAccessorFatherToSon = $this->setAccessorFromPropertyName($field);
$addAccessorFatherToSon = $this->addAccessorFromPropertyName($field);
//One to many logic
//If one to many, the erase the existent collection and generate a recursive cloning of the
//reflection object. Start from here to resolve it ( in debug check owner field )
$sons = $father->$getAccessorFatherToSon();
$father->$setAccessorFatherToSon(new ArrayCollection());
if (!is_null($sons) && !in_array($sonClassName, $exclusionMap)) {
foreach ($sons as $son) {
//cloning the son ( do recursion )
$sonCloned = $this->exploreMap(
$map,
$this->applyReflection($son, $sonClassName),
$alreadyProcessed,
$exclusionMap
);
//prevent navigability
if ($this->hasAssociation($sonClassName, $inverseField)) {
//set "new/cloned" father ref
$sonCloned->$setAccessorSonToFather($father);
}
//prevent navigability
if ($this->hasAssociation($fatherClassName, $field)) {
//add the son
$father = $father->$addAccessorFatherToSon($sonCloned);
}
}
}
return $father;
}
/**
* @param Object $father
* @param string $fatherClassName
* @param string $field
* @param string $sonClassName
* @param array $exclusionMap
*
* @return Object
*/
private function manyToManyStrategy($father, $fatherClassName, $field, $sonClassName, $exclusionMap)
{
$getAccessorFatherToSon = $this->getAccessorFromPropertyName($field, $father);
$setAccessorFatherToSon = $this->setAccessorFromPropertyName($field);
$addAccessorFatherToSon = $this->addAccessorFromPropertyName($field);
//many to many logic
//if is a many to many, then duplicate the data only in the related table
//be care about the PersistentCollection
$sons = $father->$getAccessorFatherToSon();
$associationMapping = $this->getAssociationMapping($fatherClassName, $field);
$sonsCollection = new PersistentCollection(
$this->em,
$this->metadataFactory->getMetadataFor($sonClassName),
new ArrayCollection()
);
if (!is_array($sons) && !($sons instanceof Collection)) {
$sons = new ArrayCollection();
}
$sonsCollection->setOwner($father, $associationMapping);
foreach ($sons as $son) {
$sonsCollection->hydrateAdd($son);
}
if (count($sons) > 0 && !in_array($sonClassName, $exclusionMap)) {
$father->$setAccessorFatherToSon(new ArrayCollection());
foreach ($sonsCollection->getValues() as $newSon) {
$father->$addAccessorFatherToSon($newSon);
}
} else {
$father->$setAccessorFatherToSon(new ArrayCollection());
}
return $father;
}
/**
* @param Object $father
* @param string $fatherClassName
* @param string $field
* @param string $sonClassName
* @param string $inverseField
* @param array $map
* @param array $alreadyProcessed
* @param array $exclusionMap
* @param null $callerClassName
*
* @return mixed
*/
private function oneToOneStrategy(
$father,
$fatherClassName,
$field,
$sonClassName,
$inverseField,
$map,
&$alreadyProcessed,
$exclusionMap,
$callerClassName = null
) {
$setAccessorSonToFather = $this->setAccessorFromPropertyName($inverseField);
$getAccessorFatherToSon = $this->getAccessorFromPropertyName($field, $father);
$setAccessorFatherToSon = $this->setAccessorFromPropertyName($field);
//One to One logic
$son = $father->$getAccessorFatherToSon(); //is just one son
if (!is_null($son) && $sonClassName != $callerClassName && !in_array($sonClassName, $exclusionMap)) {
//cloning the son ( do recursion )
$sonCloned = $this->exploreMap(
$map,
$this->applyReflection($son, $sonClassName),
$alreadyProcessed,
$exclusionMap,
$fatherClassName
);
//set "new/cloned" father ref
if ($this->hasAssociation($sonClassName, $inverseField)) {
$sonCloned->$setAccessorSonToFather($father);
}
if ($this->hasAssociation($fatherClassName, $field)) {
$father = $father->$setAccessorFatherToSon($sonCloned);
}
} else {
$father = $father->$setAccessorFatherToSon(null);
}
return $father;
}
private function getAccessorFromPropertyName($property, $father)
{
$suffix = ucwords(Inflector::camelize($property));
if (method_exists($father, 'is' . $suffix)) {
return 'is' . $suffix;
}
return 'get' . $suffix;
}
private function setAccessorFromPropertyName($property)
{
return 'set' . ucwords(Inflector::camelize($property));
}
private function addAccessorFromPropertyName($property)
{
$ends = substr($property, strlen($property) - 3, 3);
if ($ends == 'ies') {
$property = substr($property, 0, strlen($property) - 3) . 'y';
return 'add' . ucwords(Inflector::camelize($property));
}
return 'add' . ucwords(substr(Inflector::camelize($property), 0, strlen($property) - 1));
}
}
@jaikdean
Copy link

jaikdean commented Aug 6, 2014

This looks pretty useful. Would you be able to post the Foodity\CoreBundle\Helper\EntityHelper class?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment