« HOME

Writing a new Doctrine2 extension

By Nacho Martín on 06 May 2011

In this post I will explain how to create a new Doctrine2 extension. Extensions fulfill the role played in Doctrine1 by behaviors.

In a app that I am currently developing I faced the following situation:

So, in this scenario, what I need is an entity (History) that contains the version number of the project (that I will call status instead of version to avoid name collisions and confusions with other extensions), the id of the project, and a field to store the action messages. And I would like Doctrine to take care of the version number and increment it whenever I insert a new History entity. It is a simple behavior extension.

Why not simply using the versionable extension? Because versionable stores new versions of the entities, and I just want to store a description of the changes in between versions.

In order to start, I recommend grabbing the code https://github.com/l3pp4rd/DoctrineExtensions for two reasons: it has everything you need to start and test your code and it contains lots of examples on writing extensions.

###Tests Let’s start writing some tests, to have a clear idea of the requirements that what we need to fulfill.

<?php
//tests/Gedmo/Historyable/HistoryableEntityTest.php
namespace Gedmo\Historyable;

use Tool\BaseTestCaseORM;
use Doctrine\Common\EventManager;
use Doctrine\Common\Util\Debug,
    Historyable\Fixture\Entity\History;

class HistoryableEntityTest extends BaseTestCaseORM
{
    const HISTORY = 'Historyable\Fixture\Entity\History';
    protected function getUsedEntityFixtures()
    {
      return array(
        self::HISTORY
      );
    }

    protected function setUp()
    {
        parent::setUp();

        $evm = new EventManager;
        $this->HistoryableListener = new HistoryableListener();
        $evm->addEventSubscriber($this->HistoryableListener);
        $this->em = $this->getMockSqliteEntityManager($evm);
    }

    public function testHistoryable()
    {
        $statusRepo = $this->em->getRepository(self::HISTORY);
        $this->assertEquals(0, count($statusRepo->findAll()));

        $status0 = new History();
        $status0->setResourceId(1);
        $status0->setAction("added stuff");
        $this->em->persist($status0);
        $this->em->flush();

        $statusLast = $statusRepo->getLast(1);
        $this->assertNotEquals(null, $statusLast);
        $this->assertEquals(0, $statusLast->getStatus(), "first status is 0");

        $status1 = new History();
        $status1->setResourceId(1);
        $status1->setAction("added more stuff");
        $this->em->persist($status1);
        $this->em->flush();

        $statusLast = $statusRepo->getLast(1);
        $this->assertEquals(1, $statusLast->getStatus(), "after saving two status of the same resource, last status is 1.");

        $status2 = new History();
        $status2->setResourceId(2);
        $status2->setAction("stuff done to a different resource");
        $this->em->persist($status2);
        $this->em->flush();

        $statusLast = $statusRepo->getLast(1);
        $this->assertEquals(1, $statusLast->getStatus(), "after saving 1 status to another resource, last status is 1");
    }
}

Is it clear enough? First two functions help us to set up the environment, to be able to flush entities to the database, and have our HistoryableListener listening, and the last one contains our tests. We test three tings: that the first status will be 0, that the status is incremented when we create actions for the same resource, and that actions created to another resource don’t affect the last status count of this resource.

We need a fixture:

<?php
//tests/Gedmo/Historyable/Fixture/Entity/History.php
namespace Historyable\Fixture\Entity;

/**
 * @Entity(repositoryClass="Historyable\Fixture\Repository\HistoryRepository")
 * @gedmo:Historyable
 */

class History
{
    /**
     * @Id
     * @GeneratedValue
     * @Column(type="integer")
     */
    private $id;

    /**
    * @gedmo:refVersion
    * @Column(name="resource_id", type="integer")
    */
    private $resourceId;

    /**
     * @gedmo:status
     * @Column(type="integer")
     */
    private $status;

    /*
     * @Column(type="string")
     */
    private $action;

    /*
    * ... setters and getters ...
    */
}

We are using three custom annotations: @gedmo:Historyable marks this entity as historyable, @gedmo:status tells our extension that that field contains the status (or versions) and @gedmo:refVersion tells our extension that that field contains the id of the entity to which this status refers.

And the repository:

<?php
//tests/Gedmo/Historyable/Fixture/Repository
namespace Historyable\Fixture\Repository;

use Gedmo\Historyable\Entity\Repository\BaseHistoryRepository;

class HistoryRepository extends BaseHistoryRepository
{

}

Just an empty repository extending BaseHistoryRepository, that we will define later.

With this code, we have our tests prepared.

###Mapping We need some code to take care of these new annotations. Three definitions:

<?php
//lib/Gedmo/Historyable/Mapping/Annotations.php
namespace Gedmo\Historyable\Mapping;
use Doctrine\Common\Annotations\Annotation;

final class Historyable extends Annotation
{
}

final class RefVersion extends Annotation
{
}

final class Status extends Annotation
{
}

Now, we need a driver to transform these annotations to configuration options. This class checks that the annotations are not wrong. For instance, it checks that there is only one status field, one refVersion field, that they are integers, and so on. I won’t paste the full code here, (you can check the full source code here), because with all the checks it is quite long and repetitive.

<?php
//lib/Gedmo/Historyable/Mapping/Driver
namespace Gedmo\Historyable\Mapping\Driver;

use Gedmo\Mapping\Driver,
    Doctrine\Common\Annotations\AnnotationReader,
    Gedmo\Exception\InvalidMappingException;

class Annotation implements Driver
{
    /**
     * Annotation to define that this object is historyable
     */
    const HISTORYABLE = 'Gedmo\\Historyable\\Mapping\\Historyable';
    /**
     * Annotation to define that this property is a status
     */
    const STATUS = 'Gedmo\\Historyable\\Mapping\\Status';
    /**
     * Annotation to define that this property is a refVersion
     */
    const REFVERSION = 'Gedmo\\Historyable\\Mapping\\RefVersion';
    public function readExtendedMetadata($meta, array &amp;$config)
    {
        require_once __DIR__ . '/../Annotations.php';
        $reader = new AnnotationReader();
        $reader->setAnnotationNamespaceAlias('Gedmo\\Historyable\\Mapping\\', 'gedmo');

        $class = $meta->getReflectionClass();
        // class annotations
        $classAnnotations = $reader->getClassAnnotations($class);
        if (isset($classAnnotations[self::HISTORYABLE])) {
            $config['historyable'] = true;
        }
        // property annotations
        foreach ($class->getProperties() as $property) {
            if ($meta->isMappedSuperclass &amp;&amp; !$property->isPrivate() ||
                $meta->isInheritedField($property->name) ||
                isset($meta->associationMappings[$property->name]['inherited'])
            ) {
                continue;
            }
            if ($status = $reader->getPropertyAnnotation($property, self::STATUS)) {
                $field = $property->getName();
                if (!$meta->hasField($field)) {
                    throw new InvalidMappingException("Unable to find status [{$field}] as mapped property in entity - {$meta->name}");
                }
                $config['status_field'] = $field;
            }
            if ($refVersion = $reader->getPropertyAnnotation($property, self::REFVERSION)) {
                $field = $property->getName();
                $config['refVersion_field'] = $field;
            }
        }
    }
}

Now we define an Adapter that will contain the logic needed to get the new status number.

First, we write an interface for the adapter. This interface specifies the functions that the adapter must implement. This is because you will have usually two adapters, one for ORM (relational databases) and one for ODM (MongoDb). In this case, I will implement the ORM, but having an interface for the adapter is a good practice. It contains only a function:

<?php
//lib/Gedmo/Historyable/Mapping/Event
namespace Gedmo\Historyable\Mapping\Event;
use Gedmo\Mapping\Event\AdapterInterface;

interface HistoryableAdapter extends AdapterInterface
{
    /**
     * Get new status number
     *
     * @param ClassMetadata $meta
     * @param object $object
     * @return integer
     */
    function getNewStatus($config, $meta, $object);
}

This function will be the only and killer feature of our simple extension :). How does this function look like?

<?php
namespace Gedmo\Historyable\Mapping\Event\Adapter;
use Gedmo\Mapping\Event\Adapter\ORM as BaseAdapterORM;
use Gedmo\Historyable\Mapping\Event\HistoryableAdapter;

final class ORM extends BaseAdapterORM implements HistoryableAdapter
{
    public function getNewStatus($config, $meta, $object)
    {
        $em = $this->getObjectManager();
        $objectMeta = $em->getClassMetadata(get_class($object));
        $identifierField = $this->getSingleIdentifierFieldName($objectMeta);
        $objectId = $objectMeta->getReflectionProperty($identifierField)->getValue($object);

        //Real stuff
        $dql = "SELECT MAX(status.{$config['status_field']}) FROM {$meta->name} status";
        $dql .= " WHERE status.{$config['refVersion_field']} = :objectId";

        $q = $em->createQuery($dql);
        $q->setParameters(array(
            'objectId' => $object->getResourceId()
        ));
        $currver = $q->getSingleScalarResult();
        return $currver != null ? $currver + 1: 0;
    }
}

All this work for a single query that will tell us what will be the next status number of this History! Check out how we can make use of the array $config, that holds the names of the fields that are relevant to us, to build the query.

Event Listener

And now what? We are close to the end. We have a function that will tell us what the next status number must be, but how can we set this field whenever a new Historyable entity is inserted? Now is when the Event Subscriber comes to help us.

<?php
//lib/Gedmo/Historyable/HistoryableListener.php
namespace Gedmo\Historyable;

use Gedmo\Mapping\MappedEventSubscriber,
    Gedmo\Historyable\Mapping\Event\HistoryableAdapter,
    Doctrine\Common\EventArgs;

class HistoryableListener extends MappedEventSubscriber
{
    //we want to subscribe to these two events
    public function getSubscribedEvents()
    {
        return array(
            'onFlush',
            'loadClassMetadata',
        );
    }

    /**
     * Mapps additional metadata
     *
     * @param EventArgs $eventArgs
     * @return void
     */
    public function loadClassMetadata(EventArgs $eventArgs)
    {
        $ea = $this->getEventAdapter($eventArgs);
        $this->loadMetadataForObjectClass($ea->getObjectManager(), $eventArgs->getClassMetadata());
    }


    /**
     * Looks for historyable objects being inserted 
     *
     * @param EventArgs $args
     * @return void
     */
    public function onFlush(EventArgs $eventArgs)
    {
        $ea = $this->getEventAdapter($eventArgs);
        $om = $ea->getObjectManager();
        $uow = $om->getUnitOfWork();

        //For each object being inserted...
        foreach ($ea->getScheduledObjectInsertions($uow) as $object) {
            $meta = $om->getClassMetadata(get_class($object));
            $config = $this->getConfiguration($om, $meta->name);
            //we get the new status number and set it!
            $status = $ea->getNewStatus($config, $meta, $object);
            $object->setStatus($status);
            $ea->recomputeSingleObjectChangeSet($uow, $meta, $object);
        }
    }
}

We will listen to the onFlush event, retrieve the objects that are marked for insertion (because they are new), and compute the status number based on the refVersion field. With this, we don’t have to care about updating manually the status field anymore.

Repository

Now, won’t it be useful to have a function in our repository that retrieves the last status for a project? We can make a repository class and make our real repositories (like the one that we wrote in fixtures) to extend this, having access to that function:

<?php
//lib/Gedmo/Historyable/Entity/Repository/BaseHistoryRepository.php
namespace Gedmo\Historyable\Entity\Repository;

use Doctrine\ORM\Query,
    Doctrine\ORM\EntityRepository,
    Doctrine\ORM\EntityManager,
    Doctrine\ORM\Mapping\ClassMetadata;

class BaseHistoryRepository extends EntityRepository
{
    /**
     * Historyable listener on event manager
     *
     * @var AbstractTreeListener
     */
    protected $listener = null;

    //This checks that we are in a Historyable Entity
    public function __construct(EntityManager $em, ClassMetadata $class)
    {
        parent::__construct($em, $class);
        $histListener = null;
        foreach ($em->getEventManager()->getListeners() as $event => $listeners) {
            foreach ($listeners as $hash => $listener) {
                if ($listener instanceof \Gedmo\Historyable\HistoryableListener) {
                    $histListener = $listener;
                    break;
                }
            }
            if ($histListener) {
                break;
            }
        }
        if (is_null($histListener)) {
            throw new \Gedmo\Exception\InvalidMappingException('This repository can be attached only to ORM historyable listener');
        }
        $this->listener = $histListener;
    }
    
    //Our beloved getLast function
    public function getLast($id){
        $meta = $this->getClassMetadata();
        $config = $this->listener->getConfiguration($this->_em, $meta->name);
        $qb = $this->_em->createQueryBuilder();
        $qb->select('st')
            ->from($meta->name, 'st')
            ->orderBy('st.'.$config['status_field'], 'DESC')
            ->setMaxResults(1);
        $q = $qb->getQuery();
        return $q->getSingleResult();
    }
}

And that is all! We have just finished our extension! Now we can run the tests and see if everything went fine.

I hope this helps you somehow!

Written by @nacmartin

blog comments powered by Disqus

» ALL POSTS