Symfony2 & MongoDB ODM: Creating custom types with dependencies

Posted on by Matthias Noback

With Doctrine MongoDB ODM it is possible to add custom field types and define how its values should be converted from and to the database. But type management in the MongoDB ODM currently suffers from several design flaws. This makes it non-trivial to create a custom type, especially when your type conversion has any dependencies. This is how it works:

use Doctrine\ODM\MongoDB\Mapping\Types\Type;

Type::registerType('custom', 'Matthias\CustomTypeBundle\MongoDB\Type\CustomType');

$type = Type::getType('custom');
// $type is an instance of Matthias\CustomTypeBundle\MongoDB\Type\CustomType

As you will understand, the Type class is both a Registry and a Factory. Yet, it only allows you to define a type as a class, not as an object. This means: no constructor arguments can be passed. When using Symfony2, this implies that types can not be services, and you can use neither constructor nor setter injection.

Strangely enough, the Type class is also some kind of base class which custom types should extend. It then offers two methods that you can override, to alter the type conversion process:

namespace Matthias\CustomTypeBundle\MongoDB\Type;

use Doctrine\ODM\MongoDB\Mapping\Types\Type;

class CustomType extends Type
{
    public function convertToDatabaseValue($value)
    {
        return array_flip($value);
    }

    public function convertToPHPValue($value)
    {
        return array_flip($value);
    }
}

This demonstrates a silly example in which the field's value is an array and before storing it, its keys and values will be interchanged. Of course we should add some checks here, to prevent anything else than an array to be flipped (which would fail horribly).

Handling dependencies

Anything basic (storing arrays, integers, etc.) is already managed by the MongoDB ODM. But what if we want to do something more complicated which requires, let's say, the Doctrine ORM EntityManager. Then we should add a setter to the CustomType class, for setting the EntityManager:

use Doctrine\ORM\EntityManager;

class CustomType extends Type
{
    private $em;

    public function setEntityManager(EntityManager $em)
    {
        $this->em = $em;
    }
}

We should then make a call to Type::getType() to retrieve an instance of the type, and finally call setEntityManager() on it:

$type = Type::getType('custom');

// fetch the EntityManager, e.g. from the service container
$em = ...;

$type->setEntityManager($em);

The right time to load your custom type

The question is: when to do this? The type should be defined early, since for instance warming the cache requires you to have all types registered. The dependencies of the type may be defined later, since conversion between database and PHP values will only happen when you retrieve or store a document.

The bundle structure allows us to do exactly this: upon construction, we should register the type. The container is not available at this time, but when the bundle will be booted by the kernel it is, so then we can inject the type's dependencies:

namespace Matthias\CustomTypeBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;
use Doctrine\ODM\MongoDB\Mapping\Types\Type;

class MatthiasCustomTypeBundle extends Bundle
{
    public function __construct()
    {
        Type::registerType('custom', 'Matthias\CustomTypeBundle\MongoDB\Type\CustomType');
    }

    public function boot()
    {
        $entityManager = $this->container->get('doctrine.orm.entity_manager');
        /* @var $entityManager \Doctrine\ORM\EntityManager */

        $customType = Type::getType('custom');
        /* @var $customType \Matthias\CustomTypeBundle\MongoDB\Type\CustomType */

        $customType->setEntityManager($entityManager);
    }
}

This would of course work for any dependency you may have in your custom type.

Adding an annotation for your custom type

The only thing missing for ease-of-use is an annotation. This one is very easy, just create an annotation class (don't forget the @Annotation annotation to mark the class as an annotation:

namespace Matthias\CustomTypeBundle\MongoDB\Annotation;

use Doctrine\ODM\MongoDB\Mapping\Annotations\AbstractField;

/**
 * @Annotation
 */
class CustomType extends AbstractField
{
    public $type = 'custom';
}

The value of $type should match the name you have given to the type when you registered it.

Now you can do something like this:

namespace Matthias\CustomTypeBundle\Document;

use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB;
use Matthias\CustomTypeBundle\MongoDB\Annotation\CustomType;

/**
 * @MongoDB\Document
 */
class Test
{
    /**
     * @CustomType
     */
    private $data;

    // ...
}
PHP Symfony2 annotations MongoDB