Symfony2: Creating a Metadata Factory for Processing Custom Annotations
Matthias Noback
Earlier I wrote about how to create a custom annotation class. I used the annotation reader from Doctrine Common to check for the existence of the custom annotation inside the DocComment block. It is also possible to process the annotations on beforehand, and collect the processed data in ClassMetadata and PropertyMetadata objects. These objects are created by a MetadataFactory. The factory uses Drivers to collect the metadata.
My purpose in this article is to create a custom annotation @DefaultValue which allows me to define default values for properties of a class. It should work like this:
namespace Matthias\AnnotationBundle\Data;
use Matthias\AnnotationBundle\Annotation\DefaultValue;
class SomeClass
{
/**
* @DefaultValue("Matthias Noback")
*/
private $name;
}
At the end of this post, we should be able to automatically process an instance of this class, so that the default value “Matthias Noback” will be copied to the “name” property.
A new custom annotation class: “DefaultValue”
First, we need a custom annotation class. We can either extends this class from Doctrine\Common\Annotations\Annotation or add @Annotation to the DocComment block (I chose the latter):
namespace Matthias\AnnotationBundle\Annotation;
/**
* @Annotation
*/
class DefaultValue
{
public $value;
public function __construct(array $data)
{
$this->value = $data['value'];
}
}
The @DefaultValue annotation has one, unnamed argument. By default this argument is called “value” and it’s value will be stored in the public property “value” (this should be clear).
Define the PropertyMetadata class
Next, we need a PropertyMetadata class, in which we can store the default value that is specified by the @DefaultValue annotation:
namespace Matthias\AnnotationBundle\Metadata;
use Metadata\PropertyMetadata as BasePropertyMetadata;
class PropertyMetadata extends BasePropertyMetadata
{
public $defaultValue;
}
Creating the AnnotationDriver
Now we need an AnnotationDriver which knows how to convert @DefaultValue annotations into PropertyMetadata objects. It should look like this:
namespace Matthias\AnnotationBundle\Metadata\Driver;
use Metadata\Driver\DriverInterface;
use Metadata\MergeableClassMetadata;
use Doctrine\Common\Annotations\Reader;
use Matthias\AnnotationBundle\Metadata\PropertyMetadata;
class AnnotationDriver implements DriverInterface
{
private $reader;
public function __construct(Reader $reader)
{
$this->reader = $reader;
}
public function loadMetadataForClass(\ReflectionClass $class)
{
$classMetadata = new MergeableClassMetadata($class->getName());
foreach ($class->getProperties() as $reflectionProperty) {
$propertyMetadata = new PropertyMetadata($class->getName(), $reflectionProperty->getName());
$annotation = $this->reader->getPropertyAnnotation(
$reflectionProperty,
'Matthias\\AnnotationBundle\\Annotation\\DefaultValue'
);
if (null !== $annotation) {
// a "@DefaultValue" annotation was found
$propertyMetadata->defaultValue = $annotation->value;
}
$classMetadata->addPropertyMetadata($propertyMetadata);
}
return $classMetadata;
}
}
As you can see, it receives the Doctrine Common annotation reader as a constructor argument. When asked for a ClassMetadata object, it loops through all the properties of the given object and asks the reader for a @DefaultValue annotation. If it exists, the specified value is retrieved, and stored for later use in the PropertyMetadata object.
The MetadataFactory
Next, we need a MetadataFactory which uses the AnnotationDriver to produce a ClassMetadata object, containing a collection of PropertyMetadata objects.
We can instantiate the necessary classes and dependencies ourselves, but we’d better depend on the service container for this. So we define a “metadata_factory” service, which receives as it’s only driver the AnnotationDriver.
<?xml version="1.0" ?>
<container xmlns="https://symfony.com/schema/dic/services"
xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
<parameters>
<parameter key="matthias_annotation.metadata_factory.class">Metadata\MetadataFactory</parameter>
<parameter key="matthias_annotation.metadata.annotation_driver.class">Matthias\AnnotationBundle\Metadata\Driver\AnnotationDriver</parameter>
</parameters>
<services>
<service id="matthias_annotation.metadata.annotation_driver" class="%matthias_annotation.metadata.annotation_driver.class%" public="false">
<argument type="service" id="annotation_reader" />
</service>
<service id="matthias_annotation.metadata_factory" class="%matthias_annotation.metadata_factory.class%" public="false">
<argument type="service" id="matthias_annotation.metadata.annotation_driver" />
</service>
</services>
</container>
We now have a MetadataFactory and we can ask it for a ClassMetadata object for a given object. This ClassMetadata object will contain PropertyMetadata objects which hold the default values as provided using the @DefaultValue annotation. Now, let’s put all this to use!
For this post, I created a DefaultValueProcessor. The processor makes use of the MetadataFactory we defined above. It sets the values of the properties to the values provided by the @DefaultValue annotation:
namespace Matthias\AnnotationBundle\Data;
use Metadata\MetadataFactoryInterface;
class DefaultValueProcessor
{
private $metadataFactory;
public function __construct(MetadataFactoryInterface $metadataFactory)
{
$this->metadataFactory = $metadataFactory;
}
public function fillObjectWithDefaultValues($object)
{
if (!is_object($object)) {
throw new \InvalidArgumentException('No object provided');
}
$classMetadata = $this->metadataFactory->getMetadataForClass(get_class($object));
/* @var $metadata \Matthias\AnnotationBundle\Metadata\ClassMetadata */
foreach ($classMetadata->propertyMetadata as $propertyMetadata) {
/* @var $propertyMetadata \Matthias\AnnotationBundle\Metadata\PropertyMetadata */
if (isset($propertyMetadata->defaultValue)) {
$propertyMetadata->setValue($object, $propertyMetadata->defaultValue);
}
}
return $object;
}
}
We should provide the processor with the necessary MetadataFactory using the following service definition:
<service id="matthias_annotation.default_value_processor" class="Matthias\AnnotationBundle\Data\DefaultValueProcessor">
<argument type="service" id="matthias_annotation.metadata_factory" />
</service>
Let’s take the class from the top of this article:
namespace Matthias\AnnotationBundle\Data;
use Matthias\AnnotationBundle\Annotation\DefaultValue;
class SomeClass
{
/**
* @DefaultValue("Matthias Noback")
*/
private $name;
}
Now, if the service container is available as $this->container (for example inside a controller), try the following:
use Matthias\AnnotationBundle\Data\SomeClass;
// ...
$processor = $this->container->get('matthias_annotation.default_value_processor');
$object = new SomeClass;
$processor->fillObjectWithDefaultValues($object);
print_r($object);
This will output:
Matthias\AnnotationBundle\Data\SomeClass Object
(
[name:Matthias\AnnotationBundle\Data\SomeClass:private] => Matthias Noback
)
In my next post I will explain how to optimize performance using a (file) cache and we will create another driver, which retrieves data from a method of the class itself.