14

Question

Can I use the Doctrine entity manager (or some other Symfony function) to check if an entity has been updated?

Background

I am building a CMS with the ability to save "versions" of each page. So I have a Doctrine annotated entity $view (which is basically the "page), and this entity has nested associated entities like $view->version (which contain the majority of the information that can be updated in different revisions). This entity is edited with a standard Symfony form in the CMS. When the form is submitted, it does a $em->persist($view) and the Entity Manager detects if any of the fields have been changed. If there are changes, the changes are persisted. If there are no changes, the entity manager ignores the persist and saves itself a database call to update. Great.

But before the entity is saved, my versioning system checks if it's been more than 30 minutes since the current version was last save, or if the user submitting the form is different than the user who saved the current version, and if so it clones the $viewVersion. So the main record for $view remains the same id, but it works from an updated revision. This works great.

HOWEVER... If it's been a while since the last save, and someone just looks at the record without changing anything, and hits save, I don't want the version system to clone a new version automatically. I want to check and confirm that the entity has actually changed. The Entity Manager does this before persisting an entity. But I can't rely on it because before I call $em->persist($view) I have to clone $view->version. But before I clone $view->version I need to check if any of the fields in the entity or it's nested entities have been updated.

Basic Solution

The solution is to calculate the change set:

$form = $this->createForm(new ViewType(), $view);
if ($request->isMethod( 'POST' )) {
    $form->handleRequest($request);
    if( $form->isValid() ) {
        $changesFound = array();
        $uow = $em->getUnitOfWork();
        $uow->computeChangeSets();

        // The Version (hard coded because it's dynamically associated)
        $changeSet = $uow->getEntityChangeSet($view->getVersion());
        if(!empty($changeSet)) {
             $changesFound = array_merge($changesFound, $changeSet);
        }
        // Cycle through Each Association
        $metadata = $em->getClassMetadata("GutensiteCmsBundle:View\ViewVersion");
        $associations = $metadata->getAssociationMappings();
        foreach($associations AS $k => $v) {
            if(!empty($v['cascade'])
                && in_array('persist', $v['cascade'])
            ){
                $fn = 'get'.ucwords($v['fieldName']);
                $changeSet = $uow->getEntityChangeSet($view->getVersion()->{$fn}());
                if(!empty($changeSet)) {
                      $changesFound = array_merge($changesFound, $changeSet);
                 }
            }
        }
    }
}

The Complication

But I read that you shouldn't use this $uow->computerChangeSets() outside of a the lifecycle events listener. They say you should do a manual diff of the objects, e.g. $version !== $versionOriginal. But that doesn't work because some fields like timePublish always get updated, so they are always different. So is it really not possible to use this to getEntityChangeSets() in the context of a controller (outside of an event listener)?

How should I use an Event Listener? I don't know how to put all the pieces together.

UPDATE 1

I followed the advice and created an onFlush event listener, and presumably that should load automatically. But now the page has a big error which happens when my service definition for gutensite_cms.listener.is_versionable passes in another service of mine arguments: [ "@gutensite_cms.entity_helper" ]:

Fatal error: Uncaught exception 'Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException' with message 'Circular reference detected for service "doctrine.dbal.cms_connection", path: "doctrine.dbal.cms_connection".' in /var/www/core/cms/vendor/symfony/symfony/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php:456 Stack trace: #0 /var/www/core/cms/vendor/symfony/symfony/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php(604): Symfony\Component\DependencyInjection\Dumper\PhpDumper->addServiceInlinedDefinitionsSetup('doctrine.dbal.c...', Object(Symfony\Component\DependencyInjection\Definition)) #1 /var/www/core/cms/vendor/symfony/symfony/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php(630): Symfony\Component\DependencyInjection\Dumper\PhpDumper->addService('doctrine.dbal.c...', Object(Symfony\Component\DependencyInjection\Definition)) #2 /var/www/core/cms/vendor/symfony/symfony/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php(117): Symfony\Componen in /var/www/core/cms/vendor/symfony/symfony/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php on line 456

My Service Definition

# This is the helper class for all entities (included because we reference it in the listener and it breaks it)
gutensite_cms.entity_helper:
    class: Gutensite\CmsBundle\Service\EntityHelper
    arguments: [ "@doctrine.orm.cms_entity_manager" ]

gutensite_cms.listener.is_versionable:
    class: Gutensite\CmsBundle\EventListener\IsVersionableListener
    #only pass in the services we need
    # ALERT!!! passing this service actually causes a giant symfony fatal error
    arguments: [ "@gutensite_cms.entity_helper" ]
    tags:
        - {name: doctrine.event_listener, event: onFlush }

My Event Listener: Gutensite\CmsBundle\EventListener\isVersionableListener

class IsVersionableListener
{


    private $entityHelper;

    public function __construct(EntityHelper $entityHelper) {
        $this->entityHelper = $entityHelper;
    }

    public function onFlush(OnFlushEventArgs $eventArgs)
    {

        // this never executes... and without it, the rest doesn't work either
        print('ON FLUSH EXECUTING');
        exit;

        $em = $eventArgs->getEntityManager();
        $uow = $em->getUnitOfWork();
        $updatedEntities = $uow->getScheduledEntityUpdates();

        foreach($updatedEntities AS $entity) {

            // This is generic listener for all entities that have an isVersionable method (e.g. ViewVersion)
            // TODO: at the moment, we only want to do the following code for the viewVersion entity

            if (method_exists($entity, 'isVersionable') && $entity->isVersionable()) {

                // Get the Correct Repo for this entity (this will return a shortcut 
                // string for the repo, e.g. GutensiteCmsBundle:View\ViewVersion
                $entityShortcut = $this->entityHelper->getEntityBundleShortcut($entity);
                $repo = $em->getRepository($entityShortcut);

                // If the repo for this entity has an onFlush method, use it.
                // This allows us to keep the functionality in the entity repo
                if(method_exists($repo, 'onFlush')) {
                    $repo->onFlush($em, $entity);
                }

            }
        }

    }
}

ViewVersion Repo with onFlush Event: Gutensite\CmsBundle\Entity\View\ViewVersionRepository

/**
     * This is referenced by the onFlush event for this entity.
     *
     * @param $em
     * @param $entity
     */
    public function onFlush($em, $entity) {

        /**
         * Find if there have been any changes to this version (or it's associated entities). If so, clone the version
         * which will reset associations and force a new version to be persisted to the database. Detach the original
         * version from the view and the entity manager so it is not persisted.
         */


        $changesFound = $this->getChanges($em, $entity);

        $timeModMin = (time() - $this->newVersionSeconds);

        // TODO: remove test
        print("\n newVersionSeconds: ".$this->newVersionSeconds);
        //exit;

        /**
         * Create Cloned Version if Necessary
         * If it has been more than 30 minutes since last version entity was save, it's probably a new session.
         * If it is a new user, it is a new session
         * NOTE: If nothing has changed, nothing will persist in doctrine normally and we also won't find changes.
         */
        if($changesFound


            /**
             * Make sure it's been more than default time.
             * NOTE: the timeMod field (for View) will NOT get updated with the PreUpdate annotation
             * (in /Entity/Base.php) if nothing has changed in the entity (it's not updated).
             * So the timeMod on the $view entity may not get updated when you update other entities.
             * So here we reference the version's timeMod.
            */
            && $entity->getTimeMod() < $timeModMin
            // TODO: check if it is a new user editing
            // && $entity->getUserMod() ....
        ) {
            $this->iterateVersion($em, $entity);
        }

    }


    public function getChanges($em, $entity) {

        $changesFound = array();

        $uow = $em->getUnitOfWork();
        $changes = $uow->getEntityChangeSet($entity);

        // Remove the timePublish as a valid field to compare changes. Since if they publish an existing version, we
        // don't need to iterate a version.
        if(!empty($changes) && !empty($changes['timePublish'])) unset($changes['timePublish']);
        if(!empty($changes)) $changesFound = array_merge($changesFound, $changes);

        // The Content is hard coded because it's dynamically associated (and won't be found by the generic method below)
        $changes = $uow->getEntityChangeSet($entity->getContent());
        if(!empty($changes)) $changesFound = array_merge($changesFound, $changes);

        // Check Additional Dynamically Associated Entities
        // right now it's just settings, but if we add more in the future, this will catch any that are
        // set to cascade = persist
        $metadata = $em->getClassMetadata("GutensiteCmsBundle:View\ViewVersion");
        $associations = $metadata->getAssociationMappings();
        foreach($associations AS $k => $v) {
            if(!empty($v['cascade'])
                && in_array('persist', $v['cascade'])
            ){
                $fn = 'get'.ucwords($v['fieldName']);
                $changes = $uow->getEntityChangeSet($entity->{$fn}());
                if(!empty($changeSet)) $changesFound = array_merge($changesFound, $changes);
            }
        }

        if(!$changesFound) $changesFound = NULL;
        return $changesFound;

    }




    /**
     * NOTE: This function gets called onFlush, before the entity is persisted to the database.
     *
     * VERSIONING:
     * In order to calculate a changeSet, we have to compare the original entity with the form submission.
     * This is accomplished with a global onFlush event listener that automatically checks if the entity is versionable,
     * and if it is, checks if an onFlush method exists on the entity repository. $this->onFlush compares the unitOfWork
     * changeSet and then calls this function to iterate the version.
     *
     * In order for versioning to work, we must
     *

     *
    */


    public function iterateVersion($em, $entity) {


        $persistType = 'version';


        // We use a custom __clone() function in viewVersion, viewSettings, and ViewVersionTrait (which is on each content type)

        // It ALSO sets the viewVersion of the cloned version, so that when the entity is persisted it can properly set the settings

        // Clone the version
        // this clones the $view->version, and the associated entities, and resets the associated ids to null

        // NOTE: The clone will remove the versionNotes, so if we decide we actually want to keep them
        // We should fetch them before the clone and then add them back in manually.
        $version = clone $entity();

        // TODO: Get the changeset for the original notes and add the versionNotes back
        //$version->setVersionNotes($versionModified->getVersionNotes());

        /**
         * Detach original entities from Entity Manager
         */

        // VERSION:
        // $view->version is not an associated entity with cascade=detach, it's just an object container that we
        // manually add the current "version" to. But it is being managed by the Entity Manager, so
        // it needs to be detached

        // TODO: this can probably detach ($entity) was originally $view->getVersion()
        $em->detach($entity);

        // SETTINGS: The settings should cascade detach.

        // CONTENT:
        // $view->getVersion()->content is also not an associated entity, so we need to manually
        // detach the content as well, since we don't want the changes to be saved
        $em->detach($entity->getContent());


        // Cloning removes the viewID from this cloned version, so we need to add the new cloned version
        // to the $view as another version
        $entity->getView()->addVersion($version);


        // TODO: If this has been published as well, we need to mark the new version as the view version,
        // e.g. $view->setVersionId($version->getId())
        // This is just for reference, but should be maintained in case we need to utilize it
        // But how do we know if this was published? For the time being, we do this in the ContentEditControllerBase->persist().


    }
Community
  • 1
  • 1
Chadwick Meyer
  • 7,041
  • 7
  • 44
  • 65
  • I don't know Doctrine, but this sounds similar to your previous question. Is it worth linking that in somewhere, to show the results of your recent research? That might help guide answers. – halfer Jul 14 '14 at 22:29
  • 1
    It's a different issue, totally different parts of the code (only incidentally related to cloning). Here I'm trying to determine if we need to clone (if there have been changes). The other questions is doing the clone and trying to get it to NOT update the original entity. – Chadwick Meyer Jul 14 '14 at 22:44
  • I added more information from a recommended test. – Chadwick Meyer Jul 14 '14 at 22:48
  • I found the answer. But that creates further complications with timing. And then if I move my clone to after the $form is processed, it ends up with the same clone issue described in my original question you referenced. So looks like I do need help with that one now too... – Chadwick Meyer Jul 15 '14 at 00:37
  • You may want to code all of this yourself but there is the option of using pre built logging behaviours/extensions like https://github.com/Atlantic18/DoctrineExtensions/blob/master/doc/loggable.md or https://github.com/KnpLabs/DoctrineBehaviors#loggable. – qooplmao Jul 15 '14 at 09:03
  • @Qoop Thanks. I checked them out, and they look interesting. But if I was going to do this myself, do you know how to do what I mention in my question? 1) detach original entity that was cloned. 2) cycle through associated entities to check for changesets. – Chadwick Meyer Jul 15 '14 at 17:12
  • 1
    1) You can detach an entity using `$em->detach()` - http://docs.doctrine-project.org/en/2.0.x/reference/working-with-objects.html#detaching-entities . 2) You can get the metadata of the object using `$metadata = $em->getClassMetadata($object)` and then cycle through the associations using `$metadata->getAssociationMappings()` with some kind of recursive method situation. – qooplmao Jul 15 '14 at 18:32
  • I figured out how to detach the nested associated entities correctly. The latest question is now whether I can use the `getEntityChangeSet()` function like I'm doing... – Chadwick Meyer Jul 23 '14 at 18:17
  • I've added the code for the onFlush listener, but it's not being automatically called. It never executes for some reason. Any ideas why? – Chadwick Meyer Aug 29 '14 at 01:13
  • You should open a new question for each error you face because comments is not the best place to do this. I don't know where to answer you now :). I recommend whenever you are trying something new, that you start with a bare bones solution then build upon in. The circular reference means you are trying to create a new instance for a service while injecting another service that depends on the one you are getting. ( the chicken or the egg first? ) Your entity helper is receiving the entity manager which needs and the event listener is receiving the entity manager. – Ramy Nasr Aug 29 '14 at 03:54

3 Answers3

7

So my understanding is that you basically need to detect if doctrine is going to update an entity in the database so you can record that change or insert a version of the old entity.

The way you should do that is by adding a listener to the onFlush event. You can read more about registering doctrine events here.

For example you will need to add to your config file a new service definition like that:

my.flush.listener:
        class: Gutensite\CmsBundle\EventListener\IsVersionableListener
        calls:
            - [setEntityHelper, ["@gutensite_cms.entity_helper"]]
        tags:
            -  {name: doctrine.event_listener, event: onFlush}

Then you will create the class EventListener like any symfony service. In this class, a function with the same name as the event will be called, ( onFlush in this case )

Inside this function you can go through all updated entities:

namespace Gutensite\CmsBundle\EventListener;

class IsVersionableListener {

    private $entityHelper;

    public function onFlush(OnFlushEventArgs $eventArgs)
    {
        $em = $eventArgs->getEntityManager();
        $uow = $em->getUnitOfWork();
        $updatedEntities = $uow->getScheduledEntityUpdates();

        foreach ($updatedEntities as $entity) {
            if ($entity->isVersionable()) {
                $changes = $uow->getEntityChangeSet($entity);
                //Do what you want with the changes...
            }
        }
    }

    public function setEntityHelper($entityHelper)
    {
        $this->entityHelper = $entityHelper;

        return $this;
    }
}

$entity->isVersionable() is just a method I made up which you can add to your entities to easily decide whether this entity is tracked for changes or not.

NOTE: Since you are doing this in the onFlush. That means that all changes that will be saved to the DB have been computed. Doctrine will not persist any new entities. If you create new entities you will need to manually compute the changes and persist them.

Ramy Nasr
  • 2,367
  • 20
  • 24
  • So I create a service class, and define it in the services.yml. Now: 1) do I have to instantiate it in the controller, or will it somehow automatically just be detected and work whenever that entity is updated? I don't see how it knows to knows to do this on flush of my $view->version entity. Or is this going to happen on every entity that is flushed? – Chadwick Meyer Aug 28 '14 at 20:28
  • 2) If I need to pass in other properties from the controller, and/or pass that list of what fields have changed back to the controller, how do I do that? – Chadwick Meyer Aug 28 '14 at 20:29
  • 3) right now my controller clones the $view->version, resets the ids (so that a new records is created) and detaches the original $view->version (so that changes aren't persisted to that last revision). So will that be a problem to do that in the onFlush listener? And is there a special way of detaching that original? And is there an order/timing consideration? – Chadwick Meyer Aug 28 '14 at 20:32
  • As a practical question, where do I find documentation about what is available in the $eventArgs? And for that matter, more info about the $em->getUnitOfWork? These are like black boxes to me, and I don't have enough of the big picture yet to dig into Symfony core code and try to trace down these abstract connections. This gets to the overall question, of how new developers are supposed to take the leap from the intro book (which is very basic and easy) to these more complicated features. The cookbook isn't even very detailed in most cases. How are people learning this stuff? – Chadwick Meyer Aug 28 '14 at 21:01
  • 1.A) It appears this listener will somehow act on all entities. Thats seems way too broad. I only want to version the $view->version entity (I clone that entity and it's associated entities). Shouldn't there be a way to make sure this code doesn't act as a listener for every entity, since that is unnecessary overhead? – Chadwick Meyer Aug 28 '14 at 21:05
  • 4) If I move a lot of the code in my controller into a listener, e.g. onFlush, after I call $em->flush() in my controller, is there some way to access any variables that maybe I set in my onFlush event? So I can check the status of my functions? – Chadwick Meyer Aug 28 '14 at 21:23
  • 1
    1) The service will be instantiated by symfony when the event is fired. 2) You can pass arguments to the service by using `arguments` in `services.yml`. 3) There is not much overhead because the listener is called `onFlush`. Usually you will be flushing once unless you are doing some bulk operations. 4) you can inject one of your custom services inside the listener and use it there to store anything you want ( e.g. VersionManager service ) – Ramy Nasr Aug 28 '14 at 21:26
  • 5. If my onFlush listener needs to do some complicated functions, would I put those as functions in the listener class, or include them as some other service that is injected into this listener? For example, After I compare if there are changes, I need to ignore certain fields (e.g. timePublish, which I know is always changed), or I need to unset versionNotes when a new version is duplicated. – Chadwick Meyer Aug 28 '14 at 21:26
  • 1
    btw, this is turning into a code review rather than a stackoverflow question and answer :) – Ramy Nasr Aug 28 '14 at 21:27
  • 1.A) But if only one of my entities is going to be versioned, and I may have 100 different entities in my project (not all modified or saved at once of course), isn't it odd to make a listener that cycles through all the entities every time? Is there a different way of targeting a listener for only one entity, e.g. in the controller calling add an event subscriber to the specific $view->version? I don't know the language to use to even describe this. – Chadwick Meyer Aug 28 '14 at 21:30
  • Yes, I have a lot of questions. I don't know where to find the answers without asking people who are experts. But already the few tips you've given are very helpful. So whatever you can offer is appreciated immensely. – Chadwick Meyer Aug 28 '14 at 21:32
  • 1
    Check this code it might give you more insight https://github.com/FriendsOfSymfony/FOSUserBundle/blob/master/Doctrine/AbstractUserListener.php Also, if you are using symfony `Form` interface. You can use form events to detect changes and act on them. Not enough space to write all different possibilities and it's hard without knowing your architecture. – Ramy Nasr Aug 28 '14 at 21:33
  • 1
    btw, doctrine code is documented in the code itself ( sometimes more than the online docs). If you developed the habit of using a debugger ( xdebug for example ) and running through the code step by step. you will get a much better understanding about what is going on more than any online documentation. At least that's what I personally do. – Ramy Nasr Aug 28 '14 at 21:36
  • I've added the updated code for the onFlush listener, but it's not being automatically called. Any idea why it never executes? – Chadwick Meyer Aug 29 '14 at 01:13
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/60211/discussion-between-ramy-and-chadwick-meyer). – Ramy Nasr Aug 29 '14 at 04:23
3

First thing: there is a versionable extension for Doctrine (it was recently renamed to Loggable), that does exactly what you are describing, check that out, maybe it solves your use case.

With that said, this sounds like a job for an onFlush event listener. The UnitOfWork is already in a "changes computed" state, where you can just ask for all of the the changes on all of the entities (you can filter them with an instanceof, or something like that).

This still doesn't solve your issue about saving a new, and the old version too. I am not 100% sure this will work, because persisting something in an onFlush listener will involve workarounds (since doing a flush in an onFlush will result in an infinite loop), but there is $em->refresh($entity) that will roll back an entity to its "default" state (as it was constructed from the database).

So you can try something like, check to see if there are changes to the entity, if there are, clone it, persist the new one, refresh the old one, and save them. You will have to do extra legwork for your relations though, because cloning only creates a shallow copy in PHP.

I'd advise to go with the versionable extension, since it has everything figured out, but read up on the onFlush listener too, maybe you can come up with something.


K. Norbert
  • 10,494
  • 5
  • 49
  • 48
  • Thanks. I got `$em->detach($view->getVersion())` work so I was able to successfully detach it. It should have been easy, but they way it is found and inserted into $view and then the associated nested entities inside $view->version were complicating things. I then used Qoop's suggestion for iterating through association mapping to check the changes more automatically. Works great. – Chadwick Meyer Jul 16 '14 at 00:00
  • 1
    There is so much to learn, I get a big overwhelmed with all the existing libraries that "might" do what I need. My solution seems to work and is simple, plus I understand what it's doing. It's hard to program when there is a lot of voodoo going on, and that's what doctrine really is... black magic. I'll keep the Loggable in mind for reference though. Thanks. – Chadwick Meyer Jul 16 '14 at 00:02
  • Trying to abstract this to an onFlush event also seems really complicated. I still am not comfortable with relying on event subscribers for a lot, because it spreads the code out into so many different locations, it's hard to understand and maintain. I feel like Symfony is already too abstract. Partly because I don't know it well enough yet, maybe... but geez, to do simple things sometimes you have to jump through too many hoops. I'm not convinced that's worth the costs sometimes. – Chadwick Meyer Jul 16 '14 at 00:04
  • I've figured out how to cycle through metadata of associated fields (updated question). But now I am doubting whether I am supposed to use the `getEntityChangeSet()` function outside of an event listener. – Chadwick Meyer Jul 23 '14 at 18:18
  • I'm still stuck on this. Can help me figure out how to do this in an onFlush event? I essentially need to check what's changed, ignore the timePublish field, and then clone a new $view->version, associate that with the original $view, tell entity manager to ignore changes to the old $view->version. Would I do this in the ViewVersion entity in a lifecycle callback? Or build some other eventListener class of sorts? – Chadwick Meyer Aug 27 '14 at 23:39
  • You need an OnFlushListener, not a lifecycle callback. Sorry, I can't go into details without knowing more about your entities, and your use case. – K. Norbert Aug 28 '14 at 08:40
  • Thanks. Ya, that's what I'm looking into right now (onFlush Listener). I don't think the versionable extension is right for me, due to the way we need to store and fetch a view entity, that has multiple entire entities that are versionable, e.g. $view->version->content entire entity and $view->version->settings entire entity. It seems like versionable stores some sort of a record that is a diff for the versions? And the applies the diff to the entity when you request that version? I'm not sure how that works with searching, and fetching in an optimized way. – Chadwick Meyer Aug 28 '14 at 20:52
2

In case someone is still interested in a different way than the accepted answer (it was not working for me and I found it messier than this way in my personal opinion).

I installed the JMS Serializer Bundle and on each entity and on each property that I consider a change I added a @Group({"changed_entity_group"}). This way, I can then make a serialization between the old entity, and the updated entity and after that it's just a matter of saying $oldJson == $updatedJson. If the properties that you are interested in or that you would like to consider changes the JSON won't be the same and if you even want to register WHAT specifically changed then you can turn it into an array and search for the differences.

I used this method since I was interested mainly in a few properties of a bunch of entities and not in the entity entirely. An example where this would be useful is if you have a @PrePersist @PreUpdate and you have a last_update date, that will always be updated therefore you will always get that the entity was updated using unit of work and stuff like that.

Hope this method is helpful to anyone.

Benjamin Vison
  • 469
  • 9
  • 20
  • Some more example code snippets would make this answer even better, but I followed your train of thought regardless and I really like this method. – Yes Barry Feb 17 '20 at 16:07