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 INSERT
ed 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...