Search code examples
symfonyjmsserializerbundlesymfony-2.8jms-serializer

how to change serialization of a Doctrine Entity's attribute programmatically


I have a Dashboard Entity properly serialized/deserialized by JMSSerializer (through JMSSerializerBundle):

/**
 * @ORM\Table(name="dashboard", schema="myappID")
 * @ORM\Entity(repositoryClass="Belka\MyBundle\Entity\Repository\DashboardRepository")
 */
class Dashboard
{
    /**
     * @Id
     * @Column(type="integer")
     * @GeneratedValue("SEQUENCE")
     *
     * @Serializer\Groups({"o-all", "o-all-getCDashboard", "i-p2-editDashboard"})
     */
    protected $id;

    /**
     * @ORM\ManyToMany(targetEntity="Belka\MyBundle\Entity\User")
     *
     * @ORM\JoinTable(name="users_dashboards_associated",
     *      schema="myAppID",
     *      joinColumns={@ORM\JoinColumn(name="dashboard_id", referencedColumnName="id")},
     *      inverseJoinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")}
     *      )
     *
     * @Serializer\groups({
     *     "o-p2-create",
     *     "i-p2-create",
     *     "o-p2-patch",
     *     "i-p2-editDashboard"
     * })
     */
    protected $users;
}

and I'm using JMSSerializer's jms_serializer.doctrine_object_constructor as object constructor. Everything works like charm, but I have the following corner case: sometimes I have to set Dashboard::$users as a string (i.e. when the client sends a semantic-incorrect users property, after my checks I return the object along with a string in order to inform it. This is pretty convenient for the front-end apps). JMSSerializer takes advantage of the Doctrine's annotation, but in this case I really would like to override it programmatically, since is a very corner-case. Two are the ways on my mind:

  1. Is there a way in order to set the SerializationContext to map Dashboard::$users as a string property?
  2. Is there a way in order to change the Doctrine's Metadata before serializing it?
  3. Other options I didn't realize?

Any piece of suggestion is more than welcome


Solution

  • I've found a solution, although it does not consider nested entities's properties (has-a relations). That would mean visiting the whole graph, but I did not find the time to study the guts of the excellent JMSSSerializer. It works perfectly for forcing the first-level entity's properties though:

    first-off, a pre-serialize subscriber is needed. It will cycle over protected properties and checks if they contain a string. Is so, the type for the serialization will be overridden.

    class SerializationSubscriber implements EventSubscriberInterface
    {
    
        /**
         * @inheritdoc
         */
        static public function getSubscribedEvents()
        {
            return array(
                array('event' => 'serializer.pre_serialize', 'method' => 'onPreserialize'),
            );
        }
    
        public function onPreSerialize(PreSerializeEvent $event)
        {
            $entity = $event->getObject();
            $metadata = $event->getContext()->getMetadataFactory()->getMetadataForClass($event->getType()['name']);
            $reflect = new \ReflectionClass($entity);
            $props = $reflect->getProperties(\ReflectionProperty::IS_PROTECTED);
    
            foreach ($props as $prop) {
                $prop->setAccessible(true);
    
                if (is_string($prop->getValue($entity))) {
                    // here is the magic
                    $metadata->propertyMetadata[$prop->name]->type = array('name' => 'string', 'params' => array());
                }
            }
        }
    }
    

    Next, I didn't want to listen to this each time I serialize something. This is a corner case within one of my services. We can take advantage of the JMS\Serializer\EventDispatcher\EventDispatcher::addSubscriber, although the EventDispatcher service is declared private. So, let's turn that service into public through a compiler pass so as to take advantage of addSubscriber:

    class MyBundle extends Bundle
    {
        public function build(ContainerBuilder $container)
        {
            parent::build($container);
    
            $container->addCompilerPass(new OverrideJmsSerializerEventDispatcherDefPass());
        }
    }
    

    ... and let's turn that service into a public one

    class OverrideJmsSerializerEventDispatcherDefPass implements CompilerPassInterface
    {
        public function process(ContainerBuilder $container)
        {
            $definition = $container->getDefinition('jms_serializer.event_dispatcher');
            $definition->setPublic(true);
        }
    }
    

    Hence, we can inject it into our services. I.e. in my services.yml:

      belka.mybundle.dashboardhandler:
          class: Belka\MyBundle\Handlers\DashboardHandler
          calls:
              - [setEventDispatcher, ["@jms_serializer.event_dispatcher"]]
    

    Alright, now we can easily add our subscriber whenever we need to, without the burden of another listener each time my application is performing a serialization:

    $serializationSubscriber = new SerializationSubscriber();
    $this->eventDispatcher->addSubscriber($serializationSubscriber);
    

    Feel free to complete the answer with a solution that visits the whole Entities' graph. That would be great.