检查Symfony原则实体是否已从表格提交中更改


Check if a Symfony Doctrine Entity has Changed from Form Submission

问题

我可以使用条令实体管理器(或其他Symfony功能(检查实体是否已更新吗?

背景

我正在构建一个CMS,能够保存每个页面的"版本"。因此,我有一个带条令注释的实体$view(基本上是"页面"(,这个实体有嵌套的相关实体,如$view->version(包含可以在不同版本中更新的大部分信息(。此实体使用CMS中的标准Symfony表单进行编辑。提交表单时,它会执行$em->persist($view),实体管理器会检测是否有任何字段已更改。如果有更改,则这些更改将被持久化。如果没有更改,实体管理器将忽略持久化,并为自己保存一个数据库调用以进行更新。太棒了

但在保存实体之前,我的版本控制系统会检查自上次保存当前版本以来是否已超过30分钟,或者提交表单的用户是否与保存当前版本的用户不同,如果是,则会克隆$viewVersion。因此,$view的主记录保持相同的id,但它是从更新的修订版开始工作的。这很管用。

然而。。。如果距离上次保存已经有一段时间了,而有人只是查看记录而没有更改任何内容,然后点击保存,我不希望版本系统自动克隆新版本。我想检查并确认实体实际上已经更改。实体管理器在持久化实体之前执行此操作。但我不能依赖它,因为在调用$em->persist($view)之前,我必须克隆$view->version。但在克隆$view->version之前,我需要检查实体或其嵌套实体中的任何字段是否已更新。

基本解决方案

解决方案是计算变更集:

$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);
                 }
            }
        }
    }
}

并发症

但我读到,您不应该在生命周期事件侦听器之外使用此$uow->computerChangeSets()。他们说你应该手动区分对象,例如$version !== $versionOriginal。但这不起作用,因为像timePublish这样的一些字段总是会更新,所以它们总是不同的。那么,在控制器的上下文中(在事件侦听器之外(,是否真的不可能将其用于getEntityChangeSets()

我应该如何使用事件侦听器?我不知道如何把所有的碎片拼在一起

更新1

我遵循了建议,创建了一个onFlush事件侦听器,大概应该会自动加载。但现在页面出现了一个大错误,当我为gutensite_cms.listener.is_versionable定义的服务通过我的另一个服务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

我的服务定义

# 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 }

我的事件侦听器: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);
                }
            }
        }
    }
}

带有onFlush事件的ViewVersion Repo: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().

    }

因此,我的理解是,您基本上需要检测条令是否要更新数据库中的实体,以便记录更改或插入旧实体的版本。

这样做的方法是向onFlush事件添加一个侦听器。你可以在这里阅读更多关于注册学说事件的信息。

例如,您需要在配置文件中添加一个新的服务定义,如下所示:

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

然后,您将像创建任何symfony服务一样创建类EventListener。在这个类中,将调用一个与事件同名的函数,(本例中为onFlush(

在这个功能中,你可以浏览所有更新的实体:

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()只是我编写的一个方法,您可以将其添加到实体中,以轻松决定是否跟踪该实体的更改。

注意:因为您在onFlush中执行此操作。这意味着将保存到DB的所有更改都已计算完毕。任何新的实体都不会坚持这种学说。如果创建新实体,则需要手动计算更改并将其持久化。

第一件事:Doctrine有一个可版本化的扩展(它最近被重命名为Loggable(,它完全符合您的描述,请查看,也许它可以解决您的用例。

话虽如此,这听起来像是onFlush事件侦听器的工作。UnitOfWork已经处于"计算更改"状态,在这种状态下,您只需请求所有实体上的所有更改(您可以使用instanceof或类似的东西来过滤它们(。

这仍然不能解决您保存新版本和旧版本的问题。我不能100%确定这是否有效,因为在onFlush侦听器中持久化某些内容将涉及变通方法(因为在onFlush中执行刷新将导致无限循环(,但有$em->refresh($entity(可以将实体回滚到其"默认"状态(因为它是从数据库构建的(。

因此,您可以尝试以下操作:检查实体是否有更改,如果有,则克隆实体,保留新实体,刷新旧实体,然后保存它们。不过,您将不得不为您的关系做额外的跑腿工作,因为克隆只会在PHP中创建一个浅层副本。

我建议使用可版本控制的扩展,因为它已经解决了所有问题,但也可以在onFlush监听器上阅读,也许你可以想出一些东西。


如果有人仍然对与接受的答案不同的方式感兴趣(这对我不起作用,我发现在我个人看来比这种方式更混乱(。

我安装了JMS序列化程序捆绑包,并在我认为更改的每个实体和每个属性上添加了@Group({"changed_entity_Group"}(。这样,我就可以在旧实体和更新后的实体之间进行序列化,然后只需说$oldJson==$updatedJson。如果您感兴趣或想考虑更改的属性,JSON将不相同,如果您甚至想注册具体更改的内容,则可以将其转换为数组并搜索差异。

我之所以使用这种方法,是因为我主要对一堆实体的一些属性感兴趣,而不是完全对实体感兴趣。一个有用的例子是,如果你有一个@PrePersist@PreUpdate,并且有一个last_update日期,它将始终更新,因此你总是会得到实体是使用工作单元之类的东西更新的。

希望这个方法对任何人都有帮助。