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.
Thanks for this article - really helped me figure out how to use annotations within my applications. Will definitely be bookmarking your site and buying your book!
That's great!
Good article.. More of this deeper Symfony2/Doctrine stuff written this way!
Very helpful, your articles!
Such a wonderful article, i was looking for a clear explanation like this!
Thanks
Thank you!
Really nice post.
Eventhought, I can't find out where the Metadata\MergeableClassMetadata class comes from. I don't have this class in my vendor folder of symfony. Could you please light me up on this point ?
You probably need to upgrade the metadata library to version 1.1. The serializer requires this version already so I didn't mention it in the post.
@Jonathan: you could register a configurator in the container (maybe via compiler pass) and retrieve your classes from the container.
Good timing considering that I was just thinking about how to use a custom annotation for myself.
I wonder if there's a more elegant solution than having to call: "$processor->fillObjectWithDefaultValues($object)"