apparat/object

View on GitHub
src/Object/Infrastructure/Repository/FileAdapterStrategy.php

Summary

Maintainability
D
2 days
Test Coverage
<?php

/**
 * apparat-object
 *
 * @category    Apparat
 * @package     Apparat\Object
 * @subpackage  Apparat\Object\Infrastructure
 * @author      Joschi Kuphal <joschi@kuphal.net> / @jkphl
 * @copyright   Copyright © 2016 Joschi Kuphal <joschi@kuphal.net> / @jkphl
 * @license     http://opensource.org/licenses/MIT The MIT License (MIT)
 */

/***********************************************************************************
 *  The MIT License (MIT)
 *
 *  Copyright © 2016 Joschi Kuphal <joschi@kuphal.net> / @jkphl
 *
 *  Permission is hereby granted, free of charge, to any person obtaining a copy of
 *  this software and associated documentation files (the "Software"), to deal in
 *  the Software without restriction, including without limitation the rights to
 *  use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
 *  the Software, and to permit persons to whom the Software is furnished to do so,
 *  subject to the following conditions:
 *
 *  The above copyright notice and this permission notice shall be included in all
 *  copies or substantial portions of the Software.
 *
 *  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 *  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
 *  FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 *  COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
 *  IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 *  CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 ***********************************************************************************/

namespace Apparat\Object\Infrastructure\Repository;

use Apparat\Kernel\Ports\Kernel;
use Apparat\Object\Application\Repository\AbstractAdapterStrategy;
use Apparat\Object\Domain\Model\Object\Id;
use Apparat\Object\Domain\Model\Object\ObjectInterface;
use Apparat\Object\Domain\Model\Object\ResourceInterface;
use Apparat\Object\Domain\Model\Object\Revision;
use Apparat\Object\Domain\Model\Uri\LocatorInterface;
use Apparat\Object\Domain\Model\Uri\RepositoryLocator;
use Apparat\Object\Domain\Model\Uri\RepositoryLocatorInterface;
use Apparat\Object\Domain\Repository\AdapterStrategyInterface;
use Apparat\Object\Domain\Repository\RepositoryInterface;
use Apparat\Object\Domain\Repository\RuntimeException as DomainRepositoryRuntimeException;
use Apparat\Object\Domain\Repository\Selector;
use Apparat\Object\Domain\Repository\SelectorInterface;
use Apparat\Object\Infrastructure\Factory\ResourceFactory;
use Apparat\Object\Infrastructure\Utilities\File;
use Apparat\Object\Ports\Types\Object;
use Apparat\Resource\Domain\Model\Resource\AbstractResource;
use Apparat\Resource\Infrastructure\Io\File\AbstractFileReaderWriter;
use Apparat\Resource\Infrastructure\Io\File\Writer;

/**
 * File adapter strategy
 *
 * @package Apparat\Object
 * @subpackage Apparat\Object\Infrastructure
 */
class FileAdapterStrategy extends AbstractAdapterStrategy
{
    /**
     * Adapter strategy type
     *
     * @var string
     */
    const TYPE = 'file';
    /**
     * Glob visibilities
     *
     * @var array
     */
    protected static $globVisibilities = [
        SelectorInterface::VISIBLE => '[!.]',
        SelectorInterface::HIDDEN => '.',
        SelectorInterface::ALL => '{.,}',
    ];
    /**
     * Regex visibilities
     *
     * @var array
     */
    protected static $regexVisibilities = [
        SelectorInterface::VISIBLE => '',
        SelectorInterface::HIDDEN => '\\.',
        SelectorInterface::ALL => '\\.?',
    ];
    /**
     * Glob draft states
     *
     * @var array
     */
    protected static $globDrafts = [
        SelectorInterface::PUBLISHED => '[!.]',
        SelectorInterface::DRAFT => '.',
        SelectorInterface::ALL => '{.,}',
    ];
    /**
     * Regex draft states
     *
     * @var array
     */
    protected static $regexDrafts = [
        SelectorInterface::PUBLISHED => '',
        SelectorInterface::DRAFT => '\\.',
        SelectorInterface::ALL => '\\.?',
    ];
    /**
     * Configuration
     *
     * @var array
     */
    protected $config = null;
    /**
     * Root directory (without trailing directory separator)
     *
     * @var string
     */
    protected $root = null;
    /**
     * Configuration directory (including trailing directory separator)
     *
     * @var string
     */
    protected $configDir = null;

    /**
     * Adapter strategy constructor
     *
     * @param array $config Adapter strategy configuration
     * @throws InvalidArgumentException If the root directory configuration is empty
     * @throws InvalidArgumentException If the root directory configuration is invalid
     */
    public function __construct(array $config)
    {
        parent::__construct($config, ['root']);

        // If the root directory configuration is empty
        if (empty($this->config['root'])) {
            throw new InvalidArgumentException(
                'Empty file adapter strategy root',
                InvalidArgumentException::EMTPY_FILE_STRATEGY_ROOT
            );
        }

        // Get the real locator of the root directory
        $this->root = realpath($this->config['root']);

        // If the repository should be initialized
        if (!empty($this->config['init'])
            && (boolean)$this->config['init']
            && $this->initializeRepository()
        ) {
            $this->root = realpath($this->config['root']);
        }

        // If the root directory configuration is still invalid
        if (empty($this->root) || !@is_dir($this->root)) {
            throw new InvalidArgumentException(
                sprintf(
                    'Invalid file adapter strategy root "%s"',
                    $this->config['root']
                ),
                InvalidArgumentException::INVALID_FILE_STRATEGY_ROOT
            );
        }

        $this->configDir = $this->root.DIRECTORY_SEPARATOR.'.repo'.DIRECTORY_SEPARATOR;
    }

    /**
     * Initialize the repository
     *
     * @return boolean Success
     * @throws DomainRepositoryRuntimeException If the repository cannot be initialized
     * @throws DomainRepositoryRuntimeException If the repository size descriptor can not be created
     */
    public function initializeRepository()
    {
        // Successively create the repository directories
        $repoDirectories = [$this->config['root'], $this->config['root'].DIRECTORY_SEPARATOR.'.repo'];
        foreach ($repoDirectories as $repoDirectory) {
            // If the repository cannot be initialized
            if (file_exists($repoDirectory) ? !is_dir($repoDirectory) : !mkdir($repoDirectory, 0777, true)) {
                throw new DomainRepositoryRuntimeException(
                    'Could not initialize repository',
                    DomainRepositoryRuntimeException::REPO_NOT_INITIALIZED
                );
            }
        }

        // If the repository size descriptor can not be created
        $configDir = $this->config['root'].DIRECTORY_SEPARATOR.'.repo'.DIRECTORY_SEPARATOR;
        if ((file_exists($configDir.'size.txt') && !is_file($configDir.'size.txt'))
            || !file_put_contents($configDir.'size.txt', '0')
        ) {
            throw new DomainRepositoryRuntimeException(
                'Could not create repository size descriptor',
                DomainRepositoryRuntimeException::REPO_SIZE_DESCRIPTOR_NOT_CREATED
            );
        }

        return true;
    }

    /**
     * Find objects by selector
     *
     * @param Selector|SelectorInterface $selector Object selector
     * @param RepositoryInterface $repository Object repository
     * @return LocatorInterface[] Object locators
     */
    public function findObjectResourceLocators(SelectorInterface $selector, RepositoryInterface $repository)
    {
        chdir($this->root);

        // Build a glob string from the selector
        $glob = '';
        $globFlags = GLOB_NOSORT | GLOB_BRACE;
        $filter = 0;

        $year = $selector->getYear();
        if ($year !== null) {
            $glob .= '/'.$year;
        }

        $month = $selector->getMonth();
        if ($month !== null) {
            $glob .= '/'.$month;
        }

        $day = $selector->getDay();
        if ($day !== null) {
            $glob .= '/'.$day;
        }

        $hour = $selector->getHour();
        if ($hour !== null) {
            $glob .= '/'.$hour;
        }

        $minute = $selector->getMinute();
        if ($minute !== null) {
            $glob .= '/'.$minute;
        }

        $second = $selector->getSecond();
        if ($second !== null) {
            $glob .= '/'.$second;
        }

        // Visibility, ID & type
        $visibility = $selector->getVisibility();
        $uid = $selector->getId();
        $type = $selector->getObjectType();
        $supportedTypes = Object::getSupportedTypes();
        $types = (count($supportedTypes) == 1) ? current($supportedTypes) : '{'.implode(',', $supportedTypes).'}';
        $glob .= '/';
        $glob .= (($visibility == SelectorInterface::VISIBLE) && (is_int($uid) || ++$filter)) ?
            '' : self::$globVisibilities[$visibility];
        $glob .= $uid.'-';
        $glob .= ($type == SelectorInterface::WILDCARD) ? $types : $type;

        // Draft, revision & extension
        $draft = $selector->getDraft();
        $revision = $selector->getRevision();
        $glob .= '/'.((is_int($uid) && ($draft != SelectorInterface::PUBLISHED)) ? self::$globDrafts[$draft] : '').$uid;
        $glob .= (!is_int($revision) && (($uid != SelectorInterface::WILDCARD) || ++$filter)) ? '' : '-'.$revision;
        $glob .= '.'.getenv('OBJECT_RESOURCE_EXTENSION');


//        echo 'glob: '.$glob.'<br/>';
//        echo 'filter: '.$filter.'<br/>';
//        print_r(glob(ltrim($glob, '/'), $globFlags));

        // Find the object resources
        $objectResources = glob(ltrim($glob, '/'), $globFlags);

        // If the resources need to get post-filtered (because of visibility, draft state and / or revision)
        if ($filter) {
            $filterRegex = '/'.self::$regexVisibilities[$visibility];
            $filterRegex .= '(?P<id>\d+)-';
            $filterRegex .= (count($supportedTypes) == 1) ?
                current($supportedTypes) : '(?:'.implode('|', $supportedTypes).')';
            $filterRegex .= '/'.self::$regexDrafts[$draft];
            $filterRegex .= '(?:\\k<id>)';
            if ($revision !== Revision::CURRENT) {
                $filterRegex .= '-'.preg_quote($revision);
            }
            $filterRegex .= '\\.'.getenv('OBJECT_RESOURCE_EXTENSION');
            $objectResources = preg_grep("%$filterRegex$%", $objectResources);
        }

        // Return as repository locators
        return array_map(
            function ($objectResourcePath) use ($repository) {
                return Kernel::create(RepositoryLocator::class, [$repository, '/'.$objectResourcePath]);
            },
            $objectResources
        );
    }

    /**
     * Return an individual hash for a resource
     *
     * @param string $resourcePath Repository relative resource path
     * @return string|null Resource hash
     */
    public function getResourceHash($resourcePath)
    {
        return $this->hasResource($this->root.$resourcePath) ? File::hash($this->root.$resourcePath) : null;
    }

    /**
     * Test if an object resource exists
     *
     * @param string $resourcePath Repository relative resource path
     * @return boolean Object resource exists
     */
    public function hasResource($resourcePath)
    {
        return is_file($this->root.$resourcePath);
    }

    /**
     * Import a resource into this repository
     *
     * @param string $source Source resource
     * @param string $target Repository relative target resource locator
     * @return boolean Success
     */
    public function importResource($source, $target)
    {
        return copy($source, $this->root.$target);
    }

    /**
     * Find and return an object resource
     *
     * @param string $resourcePath Repository relative resource locator
     * @return ResourceInterface|AbstractResource Object resource
     */
    public function getObjectResource($resourcePath)
    {
        return ResourceFactory::createFromSource(AbstractFileReaderWriter::WRAPPER.$this->root.$resourcePath);
    }

    /**
     * Allocate an object ID and create an object resource
     *
     * @param \Closure $creator Object creation closure
     * @return ObjectInterface Object
     * @throws DomainRepositoryRuntimeException If no object could be created
     * @throws \Exception If another error occurs
     */
    public function createObjectResource(\Closure $creator)
    {
        $sizeDescriptor = null;

        try {
            // Open the size descriptor
            $sizeDescriptor = fopen($this->configDir.'size.txt', 'r+');

            // If a lock of the size descriptor can be acquired
            if (flock($sizeDescriptor, LOCK_EX)) {
                // Determine the current repository size
                $repositorySize = '';
                while (!feof($sizeDescriptor)) {
                    $repositorySize .= fread($sizeDescriptor, 8);
                }
                $repositorySize = intval(trim($repositorySize));

                // Instantiate the next consecutive object ID
                $nextObjectId = Kernel::create(Id::class, [++$repositorySize]);

                // Create & persist the object (bypassing the repository)
                $object = $creator($nextObjectId);
                $this->persistObject($object);

                // Dump the new repository size, unlock the size descriptor
                ftruncate($sizeDescriptor, 0);
                fwrite($sizeDescriptor, $repositorySize);
                fflush($sizeDescriptor);
                flock($sizeDescriptor, LOCK_UN);

                // Return the newly created object
                return $object;
            }

            // If no object could be created
            throw new DomainRepositoryRuntimeException(
                'The repository size descriptor is unlockable',
                DomainRepositoryRuntimeException::REPO_SIZE_DESCRIPTOR_UNLOCKABLE
            );

            // If any exception is thrown
        } catch (\Exception $e) {
            // Release the size descriptor lock
            if (is_resource($sizeDescriptor)) {
                flock($sizeDescriptor, LOCK_UN);
            }

            // Forward the thrown exception
            throw $e;
        }
    }

    /**
     * Persist an object in the repository
     *
     * @param ObjectInterface $object Object
     * @return AdapterStrategyInterface Self reference
     */
    public function persistObject(ObjectInterface $object)
    {
        // If the object has just been deleted
        if ($object->hasBeenDeleted()) {
            return $this->deleteObject($object);

            // Elseif the object has just been undeleted
        } elseif ($object->hasBeenUndeleted()) {
            return $this->undeleteObject($object);

            // If the object has just been published
        } elseif ($object->hasBeenPublished()) {
            $this->publishObject($object);
        }

        // Persist the object resource
        return $this->persistObjectResource($object);
    }

    /**
     * Delete all revisions of an object
     *
     * @param ObjectInterface $object Object
     * @return ObjectInterface Object
     */
    protected function deleteObject(ObjectInterface $object)
    {
        // Hide object directory
        $objContainerDir = dirname(dirname($this->getAbsoluteResourcePath($object->getRepositoryLocator())));
        $objContainerName = $object->getId()->getId().'-'.$object->getObjectType()->getType();
        $objPublicContainer = $objContainerDir.DIRECTORY_SEPARATOR.$objContainerName;
        $objHiddenContainer = $objContainerDir.DIRECTORY_SEPARATOR.'.'.$objContainerName;
        if (file_exists($objPublicContainer)
            && is_dir($objPublicContainer)
            && !rename($objPublicContainer, $objHiddenContainer)
        ) {
            throw new RuntimeException(
                sprintf('Cannot hide object container "%s"', $objContainerName),
                RuntimeException::CANNOT_HIDE_OBJECT_CONTAINER
            );
        }

        // Delete all object revisions
        /** @var ObjectInterface $objectRevision */
        foreach ($object as $objectRevision) {
            $this->persistObjectResource($objectRevision->delete());
        }

        return $this;
    }

    /**
     * Build an absolute repository resource locator
     *
     * @param RepositoryLocatorInterface $repositoryLocator Repository locator
     * @return string Absolute repository resource locator
     */
    public function getAbsoluteResourcePath(RepositoryLocatorInterface $repositoryLocator)
    {
        return $this->root.str_replace(
            '/',
            DIRECTORY_SEPARATOR,
            $repositoryLocator->withExtension(getenv('OBJECT_RESOURCE_EXTENSION'))
        );
    }

    /**
     * Persist an object resource in the repository
     *
     * @param ObjectInterface $object Object
     * @return AdapterStrategyInterface Self reference
     */
    protected function persistObjectResource(ObjectInterface $object)
    {
        /** @var \Apparat\Object\Infrastructure\Resource $objectResource */
        $objectResource = ResourceFactory::createFromObject($object);

        // Create the absolute object resource path
        $objectResourcePath = $this->getAbsoluteResourcePath($object->getRepositoryLocator());

        /** @var Writer $fileWriter */
        $fileWriter = Kernel::create(
            Writer::class,
            [$objectResourcePath, Writer::FILE_CREATE | Writer::FILE_CREATE_DIRS | Writer::FILE_OVERWRITE]
        );
        $objectResource->dump($fileWriter);

        return $this;
    }

    /**
     * Undelete all revisions of an object
     *
     * @param ObjectInterface $object Object
     * @return ObjectInterface Object
     */
    protected function undeleteObject(ObjectInterface $object)
    {
        // Hide object directory
        $objContainerDir = dirname(dirname($this->getAbsoluteResourcePath($object->getRepositoryLocator())));
        $objContainerName = $object->getId()->getId().'-'.$object->getObjectType()->getType();
        $objPublicContainer = $objContainerDir.DIRECTORY_SEPARATOR.$objContainerName;
        $objHiddenContainer = $objContainerDir.DIRECTORY_SEPARATOR.'.'.$objContainerName;
        if (file_exists($objHiddenContainer)
            && is_dir($objHiddenContainer)
            && !rename($objHiddenContainer, $objPublicContainer)
        ) {
            throw new RuntimeException(
                sprintf('Cannot unhide object container "%s"', $objContainerName),
                RuntimeException::CANNOT_UNHIDE_OBJECT_CONTAINER
            );
        }

        // Undelete all object revisions
        /** @var ObjectInterface $objectRevision */
        foreach ($object as $objectRevision) {
            $this->persistObjectResource($objectRevision->undelete());
        }

        return $this;
    }

    /**
     * Publish an object in the repository
     *
     * @param ObjectInterface $object
     */
    protected function publishObject(ObjectInterface $object)
    {
        $objectRepoLocator = $object->getRepositoryLocator();

        // If the object had been persisted as a draft: Remove the draft resource
        $objectDraftLocator = $objectRepoLocator->setRevision($object->getRevision()->setDraft(true));
        $absObjectDraftPath = $this->getAbsoluteResourcePath($objectDraftLocator);
        if (@file_exists($absObjectDraftPath)) {
            unlink($absObjectDraftPath);
        }

        // If it's not the first object revision: Rotate the previous revision resource
        $objectRevisionNumber = $object->getRevision()->getRevision();
        if ($objectRevisionNumber > 1) {
            // Build the "current" object repository locator
            $currentRevision = Revision::current();
            $curObjectResPath =
                $this->getAbsoluteResourcePath($objectRepoLocator->setRevision($currentRevision));

            // Build the previous object repository locator
            /** @var Revision $previousRevision */
            $previousRevision = Kernel::create(Revision::class, [$objectRevisionNumber - 1]);
            $prevObjectResPath
                = $this->getAbsoluteResourcePath($objectRepoLocator->setRevision($previousRevision));

            // Rotate the previous revision's resource path
            if (file_exists($curObjectResPath)) {
                rename($curObjectResPath, $prevObjectResPath);
            }
        }
    }

    /**
     * Return the repository size (number of objects in the repository)
     *
     * @return int Repository size
     */
    public function getRepositorySize()
    {
        $sizeDescriptorFile = $this->configDir.'size.txt';
        $repositorySize = 0;
        if (is_file($sizeDescriptorFile) && is_readable($sizeDescriptorFile)) {
            $repositorySize = intval(file_get_contents($this->configDir.'size.txt'));
        }
        return $repositorySize;
    }
}