skip to content

Writing a new Doctrine2 extension

How can we extend Doctrine with new extensions? Let's see

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:

  • People can create projects. A project can have tasks, comments, etc.
  • Projects have a version number.
  • When the client is browsing the project, he makes calls every few seconds saying: I am in version X.
  • When the client does an action (comment, open a new task…), it sends “I have just performed this action: blablabla”
  • When the server receives a new action from the client, it increments the version of the project.
  • When the server receives a “I am in version X” message and the version in the server is greater than X, it answers back saying: “user Y did action blablabla”. And the client will hopefully make use of this information to update his copy of the project using backbone.js or whatever.
  • When the server receives a “I am in version X” and the version in the server is X, it answers: “Ok, you are fine”.

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 startiniline

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!