My goal is to perform an update on a managed entity using data from an object of the same class, but non-managed by Doctrine.
This would be cool if it was possible to perform a "simple update", when replacing attributes, but in fact, if I clean an ArrayCollection
, old data seems not to be removed (even if I clean all references of the fiddle from elements of the ArrayCollection
or if orphanRemoval
is set to true).
But let's enter a specific example. I have this entity with lots of OneToOne / OneToMany relations to represent a fiddle. I can import fiddle samples (previously exported as json from another environment) using a Symfony2 command.
If the sample already exist, how can I update it properly?
I build my entity using the following code (reduced) :
$fiddle = new Fiddle();
$fiddle->setHash($this->get($json, 'hash'));
$fiddle->setRevision($this->get($json, 'revision'));
$context = $fiddle->getContext();
$context->setFormat($this->get($json, 'context', 'format'));
$context->setContent($this->get($json, 'context', 'content'));
$fiddle->clearTemplates();
$jsonTemplates = $this->get($json, 'templates') ? : array ();
foreach ($jsonTemplates as $jsonTemplate)
{
$template = new FiddleTemplate();
$template->setFilename($this->get($jsonTemplate, 'filename'));
$template->setContent($this->get($jsonTemplate, 'content'));
$template->setIsMain($this->get($jsonTemplate, 'is-main'));
$fiddle->addTemplate($template);
}
// ...
I can now persist my entity after removing it if it already exists:
$check = $this
->getContainer()
->get('doctrine')
->getRepository('FuzAppBundle:Fiddle')
->getFiddle($fiddle->getHash(), $fiddle->getRevision());
if (!is_null($check->getId()))
{
$em->remove($check);
$em->flush();
}
$em->persist($fiddle);
$em->flush();
But this will create a DELETE + INSERT instead of an UPDATE if sample already exist. This is weird because users can bookmark fiddles and the relation is made by id.
I get my fiddle first, and if it already exists, I clean it and fill it with the new data... Code works well but is really ugly, you can check it here.
As a sample, check out the tags
property: as tags might have been removed / changed, I should properly set new tags, by replacing the older by the newer.
// remove the old tags
foreach ($fiddle->getTags() as $tag)
{
if (\Doctrine\ORM\UnitOfWork::STATE_MANAGED === $em->getUnitOfWork()->getEntityState($tag))
{
$em->remove($tag);
$em->flush();
}
}
// set the new tags
$tags = new ArrayCollection();
$jsonTags = $this->getFromArray($json, 'tags');
foreach ($jsonTags as $jsonTag)
{
$tag = new FiddleTag();
$tag->setTag($jsonTag);
$tags->add($tag);
}
$fiddle->setTags($tags);
As tags are referenced using fiddle's id, I can use ->remove
even if that's ugly. This is OK here but if ids were autogenerated, there must be better solutions.
I also tried to simply set old fiddle's id to the new one and merge, but this leaded to the following exception:
[Symfony\Component\Debug\Exception\ContextErrorException]
Notice: Undefined index: 00000000125168f2000000014b64e87f
More than a simple "import feature", I want to use this update style to bind forms to non-managed entities and update existing entities only if required. So my goal is to make something generic applicable to all kind of entities.
But I of course don't expect the whole code. The good practice to deal with managed ArrayCollection
's updates, and some hints/warnings about what I should consider before coding this feature should be enough.
Update existing entities only if required.
This can be achieved fairly simple with Doctrine:
What you're looking for is the Change Tracking Policy Deferred Explicit.
Doctrine will by default use the Change Tracking Policy Deferred Implicit. This means that when you call $em->flush()
, Doctrine will go over all of its managed entities to calculate change-sets. Then all changes are persisted.
When using the Change Tracking Policy Deferred Explicit and call $em->flush()
, Doctrine will only go over the entities you've explicitly called $em->persist()
on. In other words: You could have thousands of managed entities, called $em->persist()
on 2 of them, and Doctrine will only calculate the change-sets of those 2 (and persist changes if needed).
The Change Tracking Policy can be set on an entity-class level. So if you want a certain entity class to use Deferred Explicit, simply add an annotation to the class doc-block:
/**
* @Entity
* @ChangeTrackingPolicy("DEFERRED_EXPLICIT")
*/
class Fiddle
{
Then it's just a matter of only calling $em->persist($fiddle)
when you really need to.
It's probably wise to set the same Change Tracking Policy for an entire aggregate (the root entity and all of its children).
PS: There's also a third Change Tracking Policy named Notify, which is a bit more work to set up, but gives you even more fine-grained control over what's persisted when calling $em->flush()
. But I don't think you need to go this far.
Seeing the code you use to update the Fiddle entity, I think you can improve some things there.
First move the responsibility of managing associations back to the entity:
/**
* @Entity
* @ChangeTrackingPolicy("DEFERRED_EXPLICIT")
*/
class Fiddle
{
// ...
/**
* @return FiddleTag[]
*/
public function getTags()
{
return $this->tags->toArray();
}
/**
* @param FiddleTag $tag
*/
public function addTag(FiddleTag $tag)
{
if (!$this->tags->contains($tag)) {
$this->tags->add($tag);
$tag->setFiddle($this);
}
}
/**
* @param FiddleTag $tag
*/
public function removeTag(FiddleTag $tag)
{
if ($this->tags->contains($tag)) {
$this->tags->removeElement($tag);
$tag->setFiddle(null);
}
}
/**
* @param FiddleTag[] $newTags
*/
public function replaceTags(array $newTags)
{
$currentTags = $this->getTags();
// remove tags that are not in the new list of tags
foreach ($currentTags as $currentTag) {
if (!in_array($currentTag, $newTags, true)) {
$this->removeTag($currentTag);
}
}
// add tags that are not in the current list of tags
foreach ($newTags as $newTag) {
if (!in_array($newTag, $currentTags, true)) {
$this->addTag($newTag);
}
}
}
// ...
}
Now the code in your ImportCommand can look something like this:
$jsonTags = $this->getFromArray($json, 'tags');
$newTags = [];
foreach ($jsonTags as $jsonTag) {
$tag = $tagRepo->findOneByTag($jsonTag);
if ($tag === null) {
$tag = new FiddleTag();
$tag->setTag($jsonTag);
}
$newTags[] = $tag;
}
$fiddle->replaceTags($newTags);
Then when everything is ok and can be persisted, do:
$em->persist($fiddle);
foreach ($fiddle->getTags() as $tag) {
$em->persist($tag);
}
$em->flush();
When you have configured cascade=persist
on the association, you should be able to leave out the loop that manually persists the tags.
You could have a look at the JMS Serializer library, and the Bundle that integrates it into Symfony.
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