Combining GridFS files with ORM entities

Posted on by Dennis Coorn

This article was written by Dennis Coorn.

In my previous post I wrote about uploading files to GridFS. Therefor I created a MongoDB Document with a $file property annotated with @MongoDB\File. Because I am using ORM entities more often then ODM documents, I was looking for a seamless way to access a Document from an Entity.

Because it isn't possible to define a direct relationship between an Entity and a Document I thought it would be a solid solution to create a custom field type. By defining a custom field type I can control the way the reference to the Document will be stored and at the same time I will be able to restore the reference when retrieving the field. The steps needed to create a custom field type for ORM entities are very similar to the post of Matthias on how to create custom field types for ODM documents.

Create a custom Type class

Let's start by creating an UploadType class that defines a column type called upload:

namespace Dennis\UploadBundle\Types;

use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;

class UploadType extends Type
{
    const UPLOAD = 'upload';

    public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
    {
        return $platform->getClobTypeDeclarationSQL($fieldDeclaration);
    }

    public function getName()
    {
        return self::UPLOAD;
    }

    public function requiresSQLCommentHint(AbstractPlatform $platform)
    {
        return true;
    }
}

To restore the reference to the Upload document we will need the Doctrine ODM DocumentManager to create such a reference and therefor a setter is added.

use Doctrine\ODM\MongoDB\DocumentManager;

// ...

private $dm;

public function setDocumentManager(DocumentManager $dm)
{
    $this->dm = $dm;
}

To make sure that only the id of the Upload document is being saved to the database, we override the convertToDatabaseValue method that will return the id of the document.

use Dennis\UploadBundle\Document\Upload;
use Doctrine\DBAL\Types\ConversionException;

// ...

public function convertToDatabaseValue($value, AbstractPlatform $platform)
{
    if (empty($value)) {
        return null;
    }

    if ($value instanceof Upload) {
        return $value->getId();
    }

    throw ConversionException::conversionFailed($value, self::UPLOAD);
}

To restore the reference to the Upload document after the enity has been retrieved from the database, we override the convertToPHPValue method to create and return such a reference. As you may see, creating a reference is as easy as passing the class and the id of the document to the getReference() method on the DocumentManager. Since we have chosen to return the id of the Upload document at the convertToDatabaseValue method, we can pass the supplied database value directly as the id of the document.

// ...

public function convertToPHPValue($value, AbstractPlatform $platform)
{
    if (empty($value)) {
        return null;
    }

    return $this->dm->getReference('Dennis\UploadBundle\Document\Upload', $value);
}

It's worth noting that the big advantage of creating a reference to the document, instead of using the DennisUploadBundle:Upload repository to retrieve the document, is that the document is only going to be retrieved and initiated from the database when the field, where the reference has been set on, is being requested. When you use the DennisUploadBundle:Upload repository to find the document and set it on the property, a document instance will be created for every single Image entity that will be returned by the ORM EntityManager. So on a result set of 100 entities an equal amount of documents will be created which is very inefficient. Creating a reference makes sure that the document will only be resolved when you request it.

Register our custom Type class

Now that our UploadType is capable of correctly converting the Upload document, it's time to load it into our symfony application. According to this post of Matthias the best place will be at construction of the bundle for adding the type to Doctrine and then when the bundle is being booted inject the ODM DocumentManager dependency into our UploadType.

namespace Dennis\UploadBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;
use Doctrine\DBAL\Types\Type;

class DennisUploadBundle extends Bundle
{
    public function __construct()
    {
        if (!Type::hasType('upload')) {
            Type::addType('upload', 'Dennis\UploadBundle\Types\UploadType');
        }
    }

    public function boot()
    {
        $dm = $this->container->get('doctrine.odm.mongodb.document_manager');

        /* @var $type \Dennis\UploadBundle\Types\UploadType */
        $type = Type::getType('upload');

        $type->setDocumentManager($dm);
    }
}

Using the new UploadType

To illustrate how the newly defined upload column type can be used, I will start by creating an Image entity:

namespace Acme\DemoBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 */
class Image
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\Column(type="string", length=100)
     */
    protected $name;

    /**
     * @ORM\Column(type="upload")
     */
    protected $image;

    public function getId()
    {
        return $this->id;
    }

    public function setName($name)
    {
        $this->name = $name;
    }

    public function getName()
    {
        return $this->name;
    }

    public function setImage($image)
    {
        $this->image = $image;
    }

    public function getImage()
    {
        return $this->image;
    }
}

When you take a look at the @ORM\Column annotation of the $image property you'll notice that you only have to pass the name of the UploadType as type parameter and Doctrine will use the UploadType object when storing and retrieving the $image property from the database.

Processing a form

Handling a form that has been created against our Image entity is fairly the same as any other form based on a single entity. The only addition is that you should make sure that the uploaded file is saved to GridFS and the created Upload document is set to $image property of the Image entity.

namespace Acme\DemoBundle\Controller;

use Dennis\UploadBundle\Document\Upload;

class ImageController extends Controller
{
    public function newAction(Request $request)
    {
        // ...

        $form->bind($request);

        if ($form->isValid()) {
            /** @var $upload \Symfony\Component\HttpFoundation\File\UploadedFile */
            $upload = $image->getImage();

            $document = new Upload();
            $document->setFile($upload->getPathname());
            $document->setFilename($upload->getClientOriginalName());
            $document->setMimeType($upload->getClientMimeType());

            $dm = $this->get('doctrine.odm.mongodb.document_manager');
            $dm->persist($document);
            $dm->flush();

            $image->setImage($document);

            $em = $this->getDoctrine()->getManager();
            $em->persist($image);
            $em->flush();
        }
    }
}

Now when you check the image table, after succesfully submiting the form, you will see that there has been a record created where the image field has been filled with the id of the Upload document. Automatically!

Retrieving the image

The following action method is almost exactly the same as the showAction method of the UploadController from my previous post. The only difference is that you can use the AcmeDemoBundle:Image repository to retrieve the Image entity and then get the Upload document by just calling getImage(). And again, the Upload document will only be retrieved and created from MongoDB when calling getImage(). Automatically!

/**
 * @Route("/{id}", name="image_show")
 */
public function showAction($id)
{
    $image = $this->getDoctrine()->getManager()
        ->getRepository('AcmeDemoBundle:Image')
        ->find($id);

    $response = new Response();
    $response->headers->set('Content-Type', $image->getImage()->getMimeType());

    $response->setContent($image->getImage()->getFile()->getBytes());

    return $response;
}

That's it! We now have a custom UploadType that handles references to Upload documents for our Image entity. I am pretty convinced that this approach, of creating a custom Type class, will provide an easy way of combining any ODM document with any ORM entity.

The only huge drawback is the fact that you have to manually persist the document before persisting the entity. This is definitely something you don't want to repeat in every controller that handles entities with combined documents and should be more decoupled or at least more centralized. In my next post I will try to tackle this issue, so stay tuned!

PHP Symfony2 MongoDB controller forms MongoDB