0
\$\begingroup\$

In the database, I have a table with name photographs with the following columns:

  • id
  • filename
  • type
  • size
  • caption

I want to create two classes - one is Domain Model named photo.php and the other one is a Repository class (photorepository.php).

So, I started like this:

photo.php:

class Photo
{
    private $id;
    private $filename;
    private $type;
    private $size;
    private $caption;

    public function __construct($filename, $type, $size, $caption, $id = 0)
    {
        $this->id = $id;
        $this->filename = $filename;
        $this->type = $type;
        $this->size = $size;
        $this->caption = $caption;
    }

   // setters & getters

}

Repository class (photorepository.php):

class PhotographRepository
{
    private $connection;
    private $statements = [];

    public function __construct(PDO $connection)
    {
        $this->connection = $connection;
    }

    public function create($file, $caption) {
        if(!$file || empty($file) || !is_array($file)) {
            return false;
        } elseif($file['error'] != 0) {
            return false;
        } else {
            $temp_path = $file['tmp_name'];
            $filename = basename($file['name']);
            $type = $file['type'];
            $size = $file['size'];
            if(empty($filename) || empty($temp_path)) {
                return false;
            }
            $target_path = __DIR__ . "/../public/images/" . $filename;
            if (file_exists($target_path)) {
                return false;
            }
            if(move_uploaded_file($this->temp_path, $target_path)) {

                $photo = new Photograph($filename, $type, $size, $caption);

                if (!isset($this->statements[__METHOD__])) {
                    $this->statements = $this->connection->prepare("INSERT INTO photographs (filename, type, size, caption) VALUES (:filename, :type, :size, :caption)");
                }
                $stmt = $this->statements[__METHOD__];
                if ($stmt->execute([':filename'=>$photo->getFilename(), ':type'=>$photo->getType(), ':size'=>$photo->getSize()])) {
                    $photo->setId($this->connection->lastInsertId());
                    $stmt->closeCursor();
                    unset($this->temp_path);
                    return true;
                } else {
                    $stmt->closeCursor();
                    return false;
                }
            }
        }
    }

}

$file is $_FILES["input_name"]. Is this the good way of doing it?

\$\endgroup\$
  • \$\begingroup\$ Edited: - code is ready for review (not asking for advice about code not yet written) \$\endgroup\$ – PeraMika Nov 9 '15 at 19:05
  • \$\begingroup\$ This code review should be reopened. I see nothing wrong with it anymore. \$\endgroup\$ – Greg Burghardt Nov 9 '15 at 19:57
  • \$\begingroup\$ This is actually a pretty interesting code review since it is about a well known pattern in addition to combining two different modes of persistence: Database and file storage. \$\endgroup\$ – Greg Burghardt Nov 9 '15 at 20:30
1
\$\begingroup\$

Working with images is always a messy adventure. The PhotographRepository#create method is doing a number of things:

  1. Creating a new record in the database
  2. Testing to see if the file upload exists
  3. Moving the uploaded temp file into a permanent location

The challenge in this scenario is how to handle database problems as well as file I/O problems in a manner that allows the user to try again.

Database operations are inherently safe in that if an INSERT fails on commit, the transaction gets rolled back. File operations are permanent and therefore destructive in nature. You can "undo" a file move operation, but it is not as simple is issuing a "rollback" to the file system. Due to the increased risk of losing information in I/O operations, I would recommend ensuring the database INSERT succeeds first, then attempt to move the file.

While a "photograph" seems to be just one thing, at a code level it is two things: A record in the database and a file on disk. The I/O operation should be placed in its own repository. What exists on a shared drive today might need to move to The Cloud® tomorrow. You actually have a need for two repositories:

  • PhotographRepository to handle the CRUD operations in the database
  • ImageFileRepository to handle the CRUD operations of storing the file on disk (or in The Cloud®)

The question becomes how to marry the two.

Dependent Repositories

There are times when one repository requires another repository in order to make one complete "transaction" (INSERT a record into a database, move a file on disk). There are two approaches here:

  1. Use Constructor Injection to pass the ImageFileRepository object into PhotographRepository

    $photos = new PhotographRepository(new PDO(...), new ImageFileRepository());
    
  2. Create a "repository context" object that gives you easy access to all the other repositories in your system

Which approach you take depends on how dependent each repository is on the others. If the PhotographRepository is the only one that needs another "repository" object, then go for option #1. If you have a number of repository objects that need each other, for example you need to save a parent record in the database and insert child records in other tables, then go for option #2.

Whichever direction you take, you want the file storage decoupled from the meta data stored in the database.

Using Constructor Injection for Dependent Repositories

This is the easiest to implement, but doesn't scale well as your application grows and repositories increasingly become dependent on one another. Part of our goal is to decouple the file storage from the database operations, which is a prime example of how interfaces are beneficial. The PhotographRepository class will require a second constructor argument:

class PhotographRepository
{
    private $connection;
    private $images;

    public function __construct(PDO $connection, ImageRepository $images) {
        $this->connection = $connection;
        $this->images = $images;
    }

The ImageRepository object is actually the following interface:

interface ImageRepository
{
    public function create($file);
}

The PhotographRepository#create method gets a refactoring job so the database INSERT occurs first, and then the image is moved:

class PhotographRepository
{
    // ...

    public function create($file, $caption) {
        $imageInfo = new SplFileInfo($file['tmp_name']);
        $photo = new Photograph($imageInfo->getFileName(), $file['type'], $file['size'], $caption);

        if (!isset($this->statements[__METHOD__])) {
            $this->statements = $this->connection->prepare("INSERT INTO photographs (filename, type, size, caption) VALUES (:filename, :type, :size, :caption)");
        }

        $stmt = $this->statements[__METHOD__];

        if ($stmt->execute([':filename'=>$photo->getFilename(), ':type'=>$photo->getType(), ':size'=>$photo->getSize()])) {
            $photo->setId($this->connection->lastInsertId());
            $stmt->closeCursor();
            $this->images->create($file);

            return $photo;
        } else {
            $stmt->closeCursor();

            throw new RuntimeException('Failed to save Photograph');
        }
    }

Two differences:

  1. The database operation is completed first, then the file operation is performed
  2. If the database operation fails, an exception gets thrown. being unable to insert into the database is a catastrophic problem, so throw an exception

Now let's implement the ImageRepository interface to perform the file move operation:

class ImageFileRepository implements ImageRepository
{
    private $root;

    public function __construct($root) {
        if (!file_exists($root)) {
            throw new InvalidArgumentException('Root image directory does not exist: ' . $root);
        } elseif (is_writable($root)) {
            throw new InvalidArgumentException('Root image directory "' . $root . '" is not writable');
        }

        $this->root = $root;
    }

    public function create($file) {
        $this->ensureValidFile($file);
        $targetPath = $this->root . basename($file['name']);

        if (file_exists($targetPath)) {
            throw new InvalidArgumentException('The file "' . $targetPath . '" already exists');
        }

        if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
            throw new RuntimeException('Failed to move upload file from "' . $file['tmp_name'] . '" to "' . $targetPath . '"');
        }

        return new SplFileInfo($targetPath);
    }

    private function ensureValidFile($file) {
        if (!$this->isValidFile($file)) {
            throw new InvalidArgumentException('File is not valid');
        } elseif (!is_readable($file['name'])) {
            throw new InvalidArgumentException('File is not readable');
        } elseif ($file['error'] != 0) {
            throw new InvalidArgumentException('File error: ' . $file['error']);
        }
    }

    private function isValidFile($file) {
        return $file
            && !empty($file)
            && is_array($file);
    }
}

If the any file operation cannot be completed (reads or writes) throw an exception. This is a hard-stop in your application. If your application cannot write to the public/images directory, fail early and fail loudly. A server admin needs to fix this.

Now it only takes 3 lines of code to create the photograph record and move the image:

$photos = new PhotographRepository(
    new PDO(...),
    new ImageFileRepository(__DIR__ . '/../public/images/')
);
$caption = $_POST['caption'];
$photo = $photos->create($_FILES['input_name'], $caption);

The first line of code is where you glue the two repositories together. This works well, architecturally, but if you need PhotographRepository objects in multiple places, you'll have a lot of repeated code. This is where a "Repository Context" comes in handy.

Using a Repository Context

When interdependent relationships between repositories become more commonplace, an object wrapper for your repositories is beneficial:

class RepositoryContext
{
    public $photos;
    public $images;

    public function __construct() {
        $this->images = new ImageFileRepository($this, __DIR__ . '/../public/images/');
        $this->photos = new PhotographRepository($this, new PDO(...)));
    }
}

The key feature is that each repository gets passed a reference to its $context.

Now ImageFileRepository becomes:

class ImageFileRepository implements ImageRepository
{
    private $context;
    private $root;

    public function __construct(RepositoryContext $context, $root) {
        if (!file_exists($root)) {
            throw new InvalidArgumentException('Root image directory does not exist: ' . $root);
        } elseif (is_writable($root)) {
            throw new InvalidArgumentException('Root image directory "' . $root . '" is not writable');
        }

        $this->context = $context;
        $this->root = $root;
    }

And PhotographRepository becomes:

class PhotographRepository
{
    private $context;
    private $connection;
    private $images;

    public function __construct(RepositoryContext $context, PDO $connection) {
        $this->context = $context;
        $this->connection = $connection;
        $this->images = $images;
    }

    public function create($file, $caption) {
        // ...

        if ($stmt->execute([':filename'=>$photo->getFilename(), ':type'=>$photo->getType(), ':size'=>$photo->getSize()])) {
            $photo->setId($this->connection->lastInsertId());
            $stmt->closeCursor();
            $this->context->images->create($file);

            return $photo;
        } else {
            $stmt->closeCursor();

            throw new RuntimeException('Failed to save Photograph');
        }
    }
}

After the database insert, we delegate to $this->context->images->create($file); to access the image file repository. Then it's just a slight change to the code creating the photograph:

$context = new RepositoryContext();
$caption = $_POST['caption'];
$photo = $context->photos->create($_FILES['input_name'], $caption);

Moving Image Files To The Cloud®

Later, when the Business decides to spend money on the next big buzzword, you can write another image repository to store those images on a cloud-based hosting provider:

class CloudImageRepository implements ImageRepository
{
    private $url;
    private $apiKey;

    public function __construct(RepositoryContext $context, $url, $apiKey) {
        $this->context = $context;
        $this->url = $url;
        $this->apiKey = $apiKey;
    }

    public function create($file, $caption) {
        // ...
    }
}

And just create a different repository in your context:

class RepositoryContext
{
    public $photos;
    public $images;

    public function __construct() {
        $this->images = new CloudImageRepository($this, $url, $apiKey);
        $this->photos = new PhotographRepository($this, new PDO(...)));
    }
}

Nothing else in your code needs to change.

\$\endgroup\$
  • \$\begingroup\$ Thank you Greg. For now, I checked (studied) how to do it Using Constructor Injection for Dependent Repositories. There are two small corrections: elseif (!is_writable($root)) { (missing !) and elseif (!is_readable($file['tmp_name'])) { (instead of 'name'). And it works! I will now study the part that relates to Using a Repository Context. \$\endgroup\$ – PeraMika Nov 10 '15 at 12:34
  • \$\begingroup\$ Also, one more small correction: in public function create($file, $caption) { we don't need $imageInfo = new SplFileInfo($file['tmp_name']);, because we should use $photo = new Photograph($file['name']... instead of $photo = new Photograph($imageInfo->getFileName()... , so that in the database have a file name (not temp. file name). Anyway, it works, thank you again! \$\endgroup\$ – PeraMika Nov 10 '15 at 13:03
  • \$\begingroup\$ In the case where SQL part is not successful - uploading image will not happen at all. But when SQL part is successful - uploading image may fail. In that case, is it good to call something like delete(Photograph $photo) which is defined in PhotographRepository (for example: try {...} catch (Exception $e) { $photos->delete($photo); $message = $e->getMessage(); } ? \$\endgroup\$ – PeraMika Nov 10 '15 at 15:25
  • 1
    \$\begingroup\$ If the image file upload fails just leave the temp image where it is. In fact it might be good to copy the temp image and have a cron job on the server clean up temp images older than 24 hours. \$\endgroup\$ – Greg Burghardt Nov 10 '15 at 15:27
  • 1
    \$\begingroup\$ Hmm, or maybe instead of calling some delete(...) method to delete data from DB table (when the image upload fails) - it is better to use Transactions/rollBack... I have no experience with that (Transactions/rollBack), need to read/find out how to do it... \$\endgroup\$ – PeraMika Nov 10 '15 at 16:54

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.