I'm working on a Symfony 2.3 Project that utilizes the Doctrine 2 ORM. As is to be expected functionality is split and grouped into mostly independent bundles to allow for code-reuse in other projects.
I have a UserBundle and a ContactInfoBundle. The contact info is split off because other entities could have contact information associated, however it is not inconcievable that a system may be built where users do not require said contact information. As such I'd very much prefer these two do not share any hard links.
However, creating the association mapping from the User entity to the ContactInfo entity creates a hard dependency on the ContactInfoBundle, as soon as the bundle is disabled Doctrine throws errors that ContactInfo is not within any of its registered namespaces.
My investigations have uncovered several strategies that are supposed to counter this, but none of them seem fully functional:
Doctrine 2's ResolveTargetEntityListener
This works, as long as the interface is actually replaced at runtime. Because the bundle dependency is supposed to be optional, it could very well be that there is NO concrete implementation available (i.e. contactInfoBundle is not loaded)
If there is no target entity, the entire configuration collapses onto itself because the placeholder object is not an entity (and is not within the /Entity namespace), one could theoretically link them to a Mock entity that doesn't really do anything. But this entity then gets its own table (and it gets queried), opening up a whole new can of worms.
Inverse the relation
For the ContactInfo it makes the most sense for User to be the owning side, making ContactInfo the owning side successfully sidesteps the optional part of the dependency as long as only two bundles are involved. However, as soon as a third (also optional) bundle desires an (optional) link with ContactInfo, making ContactInfo the owning side creates a hard dependency from ContactInfo on the third bundle.
Making User the owning side being logical is a specific situation. The issue however is universal where entity A contains B, and C contains B.
Use single-table inheritance
As long as the optional bundles are the only one that interacts with the newly added association, giving each bundle their own User entity that extends UserBundle\Entities\User could work. However having multiple bundles that extend a single entity rapidly causes this to become a bit of a mess. You can never be completely sure what functions are available where, and having controllers somehow respond to bundles being on and/or off (as is supported by Symfony 2's DependencyInjection mechanics) becomes largely impossible.
Any ideas or insights in how to circumvent this problem are welcome. After a couple of days of running into brick walls I'm fresh out of ideas. One would expect Symfony to have some method of doing this, but the documentation only comes up with the ResolveTargetEntityListener, which is sub-optimal.
I have finally managed to rig up a solution to this problem which would be suited for my project. As an introduction, I should say that the bundles in my architecture are laid out "star-like". By that I mean that I have one core or base bundle which serves as the base dependency module and is present in all the projects. All other bundles can rely on it and only it. There are no direct dependencies between my other bundles. I'm quite certain that this proposed solution would work in this case because of the simplicity in the architecture. I should also say that I fear there could be debugging issues involved with this method, but it could be made so that it is easily switched on or off, depending on a configuration setting, for instance.
The basic idea is to rig up my own ResolveTargetEntityListener, which would skip relating the entities if the related entity is missing. This would allow the process of execution to continue if there is a class bound to the interface missing. There's probably no need to emphasize the implication of the typo in the configuration - the class won't be found and this can produce a hard-to-debug error. That's why I'd advise to turn it off during the development phase and then turn it back on in the production. This way, all the possible errors will be pointed out by the Doctrine.
The implementation consists of reusing the ResolveTargetEntityListener's code and putting some additional code inside the remapAssociation
method. This is my final implementation:
<?php
namespace Name\MyBundle\Core;
use Doctrine\ORM\Event\LoadClassMetadataEventArgs;
use Doctrine\ORM\Mapping\ClassMetadata;
class ResolveTargetEntityListener
{
/**
* @var array
*/
private $resolveTargetEntities = array();
/**
* Add a target-entity class name to resolve to a new class name.
*
* @param string $originalEntity
* @param string $newEntity
* @param array $mapping
* @return void
*/
public function addResolveTargetEntity($originalEntity, $newEntity, array $mapping)
{
$mapping['targetEntity'] = ltrim($newEntity, "\\");
$this->resolveTargetEntities[ltrim($originalEntity, "\\")] = $mapping;
}
/**
* Process event and resolve new target entity names.
*
* @param LoadClassMetadataEventArgs $args
* @return void
*/
public function loadClassMetadata(LoadClassMetadataEventArgs $args)
{
$cm = $args->getClassMetadata();
foreach ($cm->associationMappings as $mapping) {
if (isset($this->resolveTargetEntities[$mapping['targetEntity']])) {
$this->remapAssociation($cm, $mapping);
}
}
}
private function remapAssociation($classMetadata, $mapping)
{
$newMapping = $this->resolveTargetEntities[$mapping['targetEntity']];
$newMapping = array_replace_recursive($mapping, $newMapping);
$newMapping['fieldName'] = $mapping['fieldName'];
unset($classMetadata->associationMappings[$mapping['fieldName']]);
// Silently skip mapping the association if the related entity is missing
if (class_exists($newMapping['targetEntity']) === false)
{
return;
}
switch ($mapping['type'])
{
case ClassMetadata::MANY_TO_MANY:
$classMetadata->mapManyToMany($newMapping);
break;
case ClassMetadata::MANY_TO_ONE:
$classMetadata->mapManyToOne($newMapping);
break;
case ClassMetadata::ONE_TO_MANY:
$classMetadata->mapOneToMany($newMapping);
break;
case ClassMetadata::ONE_TO_ONE:
$classMetadata->mapOneToOne($newMapping);
break;
}
}
}
Note the silent return before the switch
statement which is used to map the entity relations. If the related entity's class does not exist, the method just returns, rather than executing faulty mapping and producing the error. This also has the implication of a field missing (if it's not a many-to-many relation). The foreign key in that case will just be missing inside the database, but as it exists in the entity class, all the code is still valid (you won't get a missing method error if accidentally calling the foreign key's getter or setter).
To be able to use this code, you just have to change one parameter. You should put this updated parameter to a services file which will always be loaded or some other similar place. The goal is to have it at a place that will always be used, no matter what bundles you are going to use. I've put it in my base bundle services file:
doctrine.orm.listeners.resolve_target_entity.class: Name\MyBundle\Core\ResolveTargetEntityListener
This will redirect the original ResolveTargetEntityListener to your version. You should also clear and warm your cache after putting it in place, just in case.
I have done only a couple of simple tests which have proven that this approach might work as expected. I intend to use this method frequently in the next couple of weeks and will be following up on it if the need arises. I also hope to get some useful feedback from other people who decide to give it a go.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With