Symfony. Cómo detectar cambios en una colección de una entidad

Hoy os traigo una de esas recetas que, si bien está perfectamente documentada en la web de Doctrine, dar con ella no es tan sencillo si no estás familiarizado con el concepto de UnitOfWork y los LifeCycleEvents .

Básicamente, de lo que trata este artículo es de cómo poder detectar cambios en un campo OneToMany de una entidad, de modo que podamos por ejemplo loggearlos o crear otras entidades cuando se produce un cambio.

En mi caso, lo que necesitaba es saber qué entidades son añadidas y cuáles eliminadas de una colección. Por ejemplo, llevar un histórico de qué comentarios son añadidos o eliminados de un artículo:

Así que voy a contaros a continuación la forma en que lo he resuelto. ¡Espero que os sirva!

Explicación del evento OnFlush de Doctrine

Lo primero de todo es escoger el evento adecuado para llevar a cabo esta operación.

Dado que necesitamos que Doctrine haya calculado todos los cambios hechos en las colecciones de la entidad para poder acceder a ellos, el evento al que necesitaremos engancharnos es OnFlush , ya que, y esto es importante, el evento preUpdate no permite actualizar las relaciones de la entidad:

Changes to associations of the updated entity are never allowed in this event, since Doctrine cannot guarantee to correctly handle referential integrity at this point of the flush operation

Puede que en vuestro caso de uso sí que podáis, pero en el mío concreto la entidad de la que estoy llevando el histórico (en este artículo, la entidadPost) tiene asociado el campo historyRecords al que añadiré en el evento los cambios detectados, por lo que preUpdate no me sirve.

Dicho esto, para escuchar el evento onFlush basta con declarar nuestro servicio de la siguiente forma:

App\Doctrine\EntityListener\PostEntityListener:
tags:
- { name: doctrine.event_listener, event: onFlush }

Ojo. El evento onFlush no es un lifecycle callback, es decir, no podemos asignarle a un entidad en concreto como sí podemos hacer con preUpdate , prePersist :

Por tanto, tendremos que filtrar dentro de nuestro método para detectar cambios solo en la entidad que queramos.

Sabiendo esto, la clase PostEntityListener se nos puede quedar con el siguiente aspecto:

De este código hay que destacar 3 cosas, (prestad atención a la última que os ahorrará algún que otro quebradero de cabeza).

  • UnitOfWork me da la unidad de trabajo que va a ser procesada en el flush . Tiene diferentes métodos como getScheduledEntityInsertions , getScheduledEntityUpdates y, el que me interesa a mí, getScheduledCollectionUpdates , el cual me permite obtener los cambios en las colecciones.
  • Puesto que onFlush no es un lifecycle callback, necesito comprobar a mano que se va a actualizar una colección de la entidad Post , ya que este listener será llamado siempre que se actualice una colección.
  • Y aquí lo importante, la clase de la variable $entity es la de la entidad que posee la relación (el owning side, vamos, de ahí que estemos llamando al método getOwner). Así que dependiendo de vuestro modelo obtendréis la clase Post o la clase Comment . Recordad que el owning side es el que lleva el inversedBy.

Detectando los cambios en la colección

Una vez que ya estamos seguros de que estamos trabajando con cambios en una colección de la entidad Post , lo siguiente será detectar lo que se ha añadido y lo que se ha eliminado.

Esto es realmente sencillo, ya que la variable $collection tiene dos métodos:

  • getDeleteDiff , con las entidades eliminadas
  • getInsertdiff , con las entidades añadidas.

Por tanto, podremos escribir lo siguiente:

Lo que hago es recorrer ambos arrays e ir creando objetos de la clase HistoryRecord para registrar los comentarios eliminados o añadidos. Y con esto en teoría ya estaría, pero como siempre, queda un último escollo…

Invalid parameter number: number of bound variables does not match number of tokens

Si vais a probar directamente el listener sin añadir la línea mágica, os toparéis con este fallo, es decir, como si no se estuvieran detectando los campos de la entidad HistoryRecord recién creada.

Esto se debe a una característica propia del evento onFlush el cual nos exige lo siguiente (sacado de la documentación):

  • If you create and persist a new entity in onFlush, then calling EntityManager#persist() is not enough. You have to execute an additional call to $unitOfWork->computeChangeSet($classMetadata, $entity).
  • Changing primitive fields or associations requires you to explicitly trigger a re-computation of the changeset of the affected entity. This can be done by calling $unitOfWork->recomputeSingleEntityChangeSet($classMetadata, $entity).

Es decir, que necesitamos lanzar manualmente en nuestro caso el evento computeChangeSet para cada objeto HistoryRecord que creemos de cara a que Doctrine pueda insertarlos correctamente.

Añadiendo dicha línea a nuestro código ya tendremos la funcionalidad que buscábamos:

Enlaces de utilidad