No Tests - Part 2 - Uploading Files in EasyAdminBundle


We're about half way to a working File Upload process inside our EasyAdminBundle backend. To help figure all of this out, we're going at this process without tests. Consider this a prototype. We will then revert back to our original starting point, and redo this same process with tests.

At this stage we have the bulk of our prePersist logic in place. It's rough, but it should cover the process of moving a newly uploaded file from wherever PHP decides to temporarily store it, onto a location that we control.

The next steps are:

  • Hook up the Listener to properly call the prePersist method
  • Extract some interesting info from the uploaded file, and store it on the entity

Let's start by hooking up the listener.

# app/config/services.yml

    app.doctrine_event_listener.wallpaper_upload_listener:
        class: AppBundle\Event\Listener\WallpaperUploadListener
        arguments:
            - '@app.service.local_filesystem_file_mover'
            - "@app.service.wallpaper_file_path_helper"
        tags:
            - { name: doctrine.event_listener, event: prePersist }
            # not implemented yet, but for reference:
            # - { name: doctrine.event_listener, event: preUpdate }

We've already covered the arguments portion of this service definition in the previous video.

The three new lines are tags, and its two hashes.

Now, tags confused me for the longest time. They seem to come from nowhere (at least, nowhere I could find documented), and each tag does something slightly different.

This is largely because tags are used "behind the scenes".

Taking a step back, we already know that Symfony uses this "bundle" concept. Bundles contain code from other developers / companies that we make use of to bring functionality to our projects.

Many bundles offer us ways to hook into their functions and processes.

However, we need to be able to hook into these functions and processes without tightly tying ourselves to the bundle's code.

The way in which Symfony addresses this problem is to use tags.

Tags can contain anything - and that's partly why they seemed so confusing to me.

Each specific bundle will register its own interest in tags with a particular name.

In this instance, when the Doctrine Bundle is loaded as part of our wider project, it will look for any services that are tagged with doctrine.event_listener.

What's interesting here is that we aren't saying we are interested in prePersist or preUpdate events for just Wallpaper entities. Instead, we are saying notify this WallpaperUploadListener service whenever any dispatched preUpdate, or prePersist events take place.

This is why we need to be defensive.

There is an alternative approach to this practice - we might consider using a Doctrine Entity Listener instead. Perhaps we could investigate this approach when re-writing out implementation using tests.

You can read up on Service Tags in the official docs. In truth, simply knowing and using the right tag is typically as much as you need to know to start using the needed process. A list of available tags can be found here.

Now that we are tagged, whenever one of Doctrine's prePersist events is triggered (by Doctrine, outside of our direct control), then our listeners corresponding method will be called.

In our tag we set the event key to have the value of prePersist. This is following Doctrine's naming convention, meaning when a prePersist event is dispatched, it will call our prePersist method on our WallpaperUploadListener class. You can change this method name and so long as the event key matches with the name of your method, it should all work. However, for readability and maintainability, this is advised against.

As our method is being called by Doctrine, this explains where the $eventArgs appear from.

However, if we try to upload a file now, things don't quite work just yet:

An exception occurred while executing 'INSERT INTO wallpaper (file, filename, slug, width, height, category_id) VALUES (?, ?, ?, ?, ?, ?)' with params [{}, null, "some-file-slug", 1080, 720, null]:

SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'filename' cannot be null

When we've used either our Console Command, or our Fixtures, we have had to manually set the filename property.

As discussed in the previous video, for SEO Reasons we are very likely going to want to set our filenames manually before uploading. To quickly recap, this is to ensure we can have nice image names like "lovely-crab-horse-nebula.jpg" instead of "89723-abc-28be.jpg". It's all about the Googles.

Now, we could choose to get fancy here and allow our users to change the filename at the point of upload. But for the moment, we will keep things simple whilst we focus on the wider upload process.

We saw some code in the previous video which will give us direct access to the uploaded filename:

        $newFilePath = $this->wallpaperFilePathHelper
            ->getNewFilePath(
                $file->getClientOriginalName()
            )
        ;

Specifically:

$file->getClientOriginalName()

Now, just to re-iterate - if you are allowing end users - or untrusted users in general - to upload their photos, don't trust them. Use a randomised image name. As an example:

    public function generateFilename(UploadedFile $file)
    {
        return md5(uniqid()).'.'.$file->guessExtension();
    }

Adapt and improve as necessary.

Anyway, if we have the filename, we can simply update our entity accordingly. Remember, at this stage we are "pre persist". In other words, the new bits of data have not yet been INSERTed into the database:

<?php

// src/AppBundle/Event/Listener/WallpaperUploadListener.php

    public function prePersist(LifecycleEventArgs $eventArgs)
    {
        $entity = $eventArgs->getEntity();

        if (false === $entity instanceof Wallpaper) {
            return false;
        }

        /**
         * @var $entity Wallpaper
         */

        // get access to the file
        $file = $entity->getFile();

        $newFilePath = $this->wallpaperFilePathHelper
            ->getNewFilePath(
                $file->getClientOriginalName()
            )
        ;

        // move the uploaded file
        // args: $currentPath, $newPath
        $this->fileMover->move(
            $file->getPathname(),
            $newFilePath
        );

        // update the Wallpaper entity with new info
        $entity
            ->setFilename(
                $file->getClientOriginalName()
            )
        ;

        return true;
    }

And at last, we can successfully upload new Wallpaper entities. Cool.

Let's make this process a touch better by dynamically determining the images width and height:

<?php

// src/AppBundle/Event/Listener/WallpaperUploadListener.php

        [
            0 => $width,
            1 => $height,
        ] = getimagesize($newFilePath);

        // update the Wallpaper entity with new info
        $entity
            ->setFilename(
                $file->getClientOriginalName()
            )
            ->setWidth($width)
            ->setHeight($height)
        ;

        return true;
    }

If this looks new to you, be sure to check out this video where we covered this in more depth.

We can now remove the fields for width and height on the EasyAdminBundle form configuration:

# app/config/config/easy_admin_bundle.yml

easy_admin:
    entities:
        Category:
            class: AppBundle\Entity\Category
        Wallpaper:
            class: AppBundle\Entity\Wallpaper
            list:
                fields:
                    - "id"
                    - "filename"
                    - "slug"
                    - { property: "width", format: "%d" }
                    - { property: "height", format: "%d" }
                    - { property: "image", type: "image", base_path: "/images/" }
            form:
                fields:
                    - { property: "file", type: "file", label: "File" }
                    - "slug"

Ok, before we finish up we have an issue to resolve here. If we look in the database now, we see some utter funk under the file column:

/private/var/folders/kl/wm7jbqv95q72s34qv1559gmr0000gp/T/php2fwYJX

We don't need this.

We can safely remove the entity annotation for our file property, and rely on alternative mechanisms to display our images. For example, we know that by combining the filename with the expected images path (symfony project dir + /web/images) then we can find our wallpaper image file again.

Let's update our entity to reflect our new system knowledge:

// src/AppBundle/Entity/Wallpaper.php

use Symfony\Component\HttpFoundation\File\File;

    /**
     * @var File
     */
    private $file;

    /**
     * @return File
     */
    public function getFile()
    {
        return $this->file;
    }

    /**
     * @param File $file
     * @return Wallpaper
     */
    public function setFile(File $file)
    {
        $this->file = $file;

        return $this;
    }

And both diff and migrate our database schema:

php bin/console doctrine:migrations:diff

Generated new migration class to "/path/to/my/project/wallpaper/app/DoctrineMigrations/Version20170626095318.php" from schema differences.

<?php

namespace Application\Migrations;

use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;

/**
 * Auto-generated Migration: Please modify to your needs!
 */
class Version20170626095318 extends AbstractMigration
{
    /**
     * @param Schema $schema
     */
    public function up(Schema $schema)
    {
        // this up() migration is auto-generated, please modify it to your needs
        $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');

        $this->addSql('ALTER TABLE wallpaper DROP file');
    }

    /**
     * @param Schema $schema
     */
    public function down(Schema $schema)
    {
        // this down() migration is auto-generated, please modify it to your needs
        $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');

        $this->addSql('ALTER TABLE wallpaper ADD file VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci');
    }
}

and:

php bin/console doctrine:migrations:migrate

                    Application Migrations

WARNING! You are about to execute a database migration that could result in schema changes and data lost. Are you sure you wish to continue? (y/n)y
Migrating up to 20170626095318 from 20170612135209

  ++ migrating 20170626095318

     -> ALTER TABLE wallpaper DROP file

  ++ migrated (0.27s)

  ------------------------

  ++ finished in 0.27s
  ++ 1 migrations executed
  ++ 1 sql queries

The last thing to point out here is that repeating this process may fail if you use the same slug twice. Remember, the slug must be unique.

Now we have seen - roughly - how the process might work. There are alternative implementations - and as this is code, there are likely plenty of them.

We've done this without tests to cover the general outline of what needs to happen to make this process work.

Now that we have that process in our heads, let's look at how we might do this in a test-driven manner. Will it be easier, or harder? There's only one way to find out...

Code For This Course

Get the code for this course.

Episodes

# Title Duration
1 Introduction and Site Demo 02:14
2 Setup and a Basic Wallpaper Gallery 08:43
3 Pagination 08:24
4 Adding a Detail View 04:47
5 Creating a Home Page 11:14
6 Creating our Wallpaper Entity 07:50
7 Wallpaper Setup Command - Part 1 - Symfony Commands As a Service 05:57
8 Wallpaper Setup Command - Part 2 - Injection Is Easy 08:53
9 Wallpaper Setup Command - Part 3 - Doing It With Style 05:37
10 Doctrine Fixtures - Part 1 - Setup and Category Entity Creation 08:52
11 Doctrine Fixtures - Part 2 - Relating Wallpapers with Categories 05:56
12 EasyAdminBundle - Setup and Category Configuration 06:02
13 EasyAdminBundle - Wallpaper Setup and List View 07:46
14 EasyAdminBundle - Starting with Wallpaper Uploads 05:57
15 Testing with PhpSpec to Guide Our Implementation 03:39
16 Using PhpSpec to Test our FileMover 05:34
17 Symfony Dependency Testing with PhpSpec 08:47
18 Defensive Counter Measures 06:33
19 No Tests - Part 1 - Uploading Files in EasyAdminBundle 11:01
20 No Tests - Part 2 - Uploading Files in EasyAdminBundle 07:05
21 Don't Mock What You Don't Own 09:36
22 You've Got To Take The Power Back 07:36
23 Making Symfony Work For Us 08:56
24 Testing The Wallpaper File Path Helper 15:11
25 Finally, It Works! 14:56
26 Why I Prefer Not To Use Twig 16:51
27 Fixing The Fixtures 11:20
28 Untested Updates 14:30
29 Untested Updates Part Two - Now We Can Actually Update 06:33
30 Adding an Image Preview On Edit 12:31
31 Delete Should Remove The Wallpaper Image File 11:02
32 Getting Started Testing Wallpaper Updates 10:02
33 Doing The Little Before The Big 08:13
34 Tested Image Preview... Sort Of :) 07:36
35 Finishing Up With a Tested Wallpaper Update 10:41
36 Test Driven Wallpaper Delete - Part 1 11:06
37 Test Driven Wallpaper Delete - Part 2 11:57
38 EasyAdminBundle Login Form Tutorial 08:01