RebelCode/migrations

View on GitHub
src/AbstractMigrator.php

Summary

Maintainability
A
0 mins
Test Coverage
<?php

namespace RebelCode\Migrations;

use ByJG\DbMigration\Exception\DatabaseIsIncompleteException;
use ByJG\DbMigration\Exception\DatabaseNotVersionedException;
use ByJG\DbMigration\Migration as ByjgMigration;
use Dhii\Util\String\StringableInterface as Stringable;
use Exception as RootException;
use InvalidArgumentException;
use RebelCode\Migrations\Exception\CouldNotMigrateExceptionInterface;
use RegexIterator;
use Symfony\Component\Finder\Iterator\RecursiveDirectoryIterator;

/**
 * Abstract functionality for migrations.
 *
 * @since [*next-version*]
 */
abstract class AbstractMigrator extends ByjgMigration
{
    /**
     * Constant for "up" direction migration.
     *
     * @since [*next-version*]'
     */
    const MIGRATION_DIRECTION_UP = 'up';

    /**
     * Constant for "down" direction migration.
     *
     * @since [*next-version*]
     */
    const MIGRATION_DIRECTION_DOWN = 'down';

    /**
     * {@inheritdoc}
     *
     * Overridden to wrap around the parent method and re-throw any exceptions as specific exceptions.
     *
     * @since [*next-version*]
     */
    protected function _up($upVersion = null, $force = false)
    {
        try {
            parent::up($upVersion, $force);
        } catch (RootException $exception) {
            throw $this->_createCouldNotMigrateException(
                $this->__('Failed to migrate up'),
                null,
                $exception,
                $upVersion
            );
        }
    }

    /**
     * {@inheritdoc}
     *
     * Overridden to wrap around the parent method and re-throw any exceptions as specific exceptions.
     *
     * @since [*next-version*]
     */
    protected function _down($upVersion = null, $force = false)
    {
        try {
            parent::down($upVersion, $force);
        } catch (RootException $exception) {
            throw $this->_createCouldNotMigrateException(
                $this->__('Failed to migrate down'),
                null,
                $exception,
                $upVersion
            );
        }
    }

    /**
     * Resets the database to the base version, and then optionally up to a specific version.
     *
     * Almost identical to {@link ByjgMigration::reset()}, except that it catches and throws
     * specific exceptions and does not attempt to perform "up" migration after resetting.
     *
     * @since [*next-version*]
     */
    protected function _reset()
    {
        try {
            try {
                $this->getCurrentVersion();
                $this->down(0);
            } catch (DatabaseNotVersionedException $versionedException) {
                // Do nothing
            }

            $db = $this->getDbCommand();
            $db->createVersion();
            $db->setVersion(0, 'complete');
        } catch (RootException $exception) {
            throw $this->_createCouldNotMigrateException(
                $this->__('Failed to reset'),
                null,
                $exception,
                0
            );
        }
    }

    /**
     * Executes a migration.
     *
     * Overridden to:
     * - pass different arguments to the callback progress function
     * - allow preparation of SQL query strings before execution
     * - contain a portion of the fix for the SQL file execution bug See {@link getMigrationSqlQuery()}.
     * - throw specific exceptions
     *
     * @param int  $upVersion
     * @param int  $increment Can accept 1 for UP or -1 for down
     * @param bool $force
     *
     * @throws DatabaseIsIncompleteException
     */
    protected function migrate($upVersion, $increment, $force)
    {
        $versionInfo    = $this->getCurrentVersion();
        $currentVersion = $this->_normalizeInt($versionInfo['version']);

        if (strpos($versionInfo['status'], 'partial') !== false && !$force) {
            throw new DatabaseIsIncompleteException(
                'Database was not fully updated - use the "force" argument to ignore this error'
            );
        }

        while (
            $this->canContinue($currentVersion, $upVersion, $increment)
            &&
            $rawSql = $this->_getMigrationSqlQuery($currentVersion, $increment)
        ) {
            $nextVersion = $currentVersion + $increment;

            $preparedSql = $this->_prepareSql($rawSql);

            $this->getDbCommand()->setVersion($nextVersion, 'partial ' . ($increment > 0 ? 'up' : 'down'));
            $this->getDbCommand()->executeSql($preparedSql);
            $this->getDbCommand()->setVersion($nextVersion, 'complete');

            $currentVersion = $nextVersion;
        }
    }

    /**
     * {@inheritdoc}
     *
     * Overridden to contain a portion of the fix for the SQL file execution bug. See {@link getMigrationSqlQuery()}.
     * This method now properly checks if migration can be done from the current version to another version,
     * including/excluding the current version depending on the increment.
     *
     * This is achieved by calculating the difference between the versions and multiplying by the increment. This result
     * is called "delta", and it allows the detection of a discrepancy between the given versions and the sign of the
     * increment by swapping the sign of the difference. If delta is zero or smaller, then the given versions and the
     * increment are not consistent.
     *
     * Example:
     * 1. If the up version is greater than the current version, the increment should be positive, their difference will
     *    be positive and so delta will be positive.
     * 2. If the up version is smaller than the current version, the increment should be negative, their difference will
     *    be negative and so delta will be positive.
     *
     * @since [*next-version*]
     */
    protected function canContinue($currentVersion, $upVersion, $increment)
    {
        // Difference between versions
        $diff  = $this->_normalizeInt($upVersion) - $this->_normalizeInt($currentVersion);
        $delta = $diff * $increment;

        return $delta > 0 || $upVersion === null;
    }

    /**
     * Retrieves the SQL query string for a particular version and increment (direction).
     *
     * When migrating up from version `x` to any other version more recent than `x`, the SQL query returned is the "up"
     * migration SQL for version `x` + 1.
     *
     * When migrating down from version `x` to any other version older than `x`, the SQL query returned is the "down"
     * migration SQL for version `x`.
     *
     * Every version's up and down migration SQL queries are treated as commit-style changes. Invoking `x` up will
     * apply the changes named `x` to the database. Conversely, invoking `x` down will undo those changes.
     *
     * @since [*next-version*]
     *
     * @param int $currVersion The current version.
     * @param int $increment   The migration increment.
     *
     * @return null|string The SQL, or null if no SQL was found for the given version.
     */
    protected function _getMigrationSqlQuery($currVersion, $increment)
    {
        $currVersion = $this->_normalizeInt($currVersion);
        $fileNumber  = ($increment >= 0)
            ? $currVersion + 1
            : $currVersion;

        $file = $this->getMigrationSql($fileNumber, $increment);

        return file_exists($file) && is_readable($file)
            ? file_get_contents($file)
            : null;
    }

    /**
     * Retrieves the path to an SQL file.
     *
     * Overridden to use the new directory customization.
     *
     * @since [*next-version*]
     *
     * @param int|string $version   The version.
     * @param int        $increment The increment.
     *
     * @return string|null The path to the SQL file or null if no matching file was found.
     */
    public function getMigrationSql($version, $increment)
    {
        $version = $this->_normalizeInt($version);
        $files   = $this->_getMigrationFiles($version, $increment);
        $count   = count($files);

        if ($count > 1) {
            throw $this->_createCouldNotMigrateException(
                $this->__('Found multiple migration files with the same version number'),
                null,
                null,
                $version
            );
        }

        return $count
            ? reset($files)
            : null;
    }

    /**
     * Retrieves the migration files that match the given version and increment.
     *
     * @since [*next-version*]
     *
     * @param int $version   The migration version.
     * @param int $increment The increment, either 1 or -1.
     *
     * @return string[] The matched migration file paths.
     */
    protected function _getMigrationFiles($version, $increment)
    {
        $results   = [];
        $direction = ($increment < 0)
            ? static::MIGRATION_DIRECTION_DOWN
            : static::MIGRATION_DIRECTION_UP;

        $patterns = $this->_getMigrationFilePatterns($direction);

        foreach ($patterns as $_dir => $_pattern) {
            $_regex  = sprintf($_pattern, $version);
            $_files  = $this->_getMatchingFiles($_dir, $_regex);
            $results = array_merge($results, $_files);
        }

        return $results;
    }

    /**
     * Retrieves the path of all files in a directory that match a given regex pattern.
     *
     * @since [*next-version*]
     *
     * @param string $directory
     * @param        $regex
     *
     * @return array
     */
    protected function _getMatchingFiles($directory, $regex)
    {
        $directory = rtrim($directory, '/\\');
        $directory = $this->_normalizeString($directory);
        $regex     = $this->_normalizeString($regex);

        if (!is_dir($directory)) {
            return [];
        }

        $dirIterator = new RecursiveDirectoryIterator(
            $directory,
            RecursiveDirectoryIterator::KEY_AS_FILENAME |
            RecursiveDirectoryIterator::CURRENT_AS_FILEINFO |
            RecursiveDirectoryIterator::SKIP_DOTS
        );
        $regexIterator = new RegexIterator($dirIterator, $regex, RegexIterator::MATCH, RegexIterator::USE_KEY);

        return iterator_to_array($regexIterator);
    }

    /**
     * Retrieves the regex patterns for finding migration files for a specific direction.
     *
     * Regex patterns must contain a "%d" at the string location where the migration version is found.
     * This will be interpolated into the version number of the migration.
     *
     * @since [*next-version*]
     *
     * @param string|Stringable|null $direction The direction of the migration. See the MIGRATION_DIRECTION_* constants.
     *
     * @return string[] An array of file name matching regex strings, mapped to directory strings.
     */
    abstract protected function _getMigrationFilePatterns($direction = null);

    /**
     * Prepares the SQL for execution.
     *
     * @since [*next-version*]
     *
     * @param string $sql The SQL to execute.
     *
     * @return string The prepared SQL.
     */
    abstract protected function _prepareSql($sql);

    /**
     * Normalizes a value into an integer.
     *
     * The value must be a whole number, or a string representing such a number,
     * or an object representing such a string.
     *
     * @since [*next-version*]
     *
     * @param string|Stringable|float|int $value The value to normalize.
     *
     * @throws InvalidArgumentException If value cannot be normalized.
     *
     * @return int The normalized value.
     */
    abstract protected function _normalizeInt($value);

    /**
     * Normalizes a value to its string representation.
     *
     * The values that can be normalized are any scalar values, as well as
     * {@see StringableInterface).
     *
     * @since [*next-version*]
     *
     * @param Stringable|string|int|float|bool $subject The value to normalize to string.
     *
     * @throws InvalidArgumentException If the value cannot be normalized.
     *
     * @return string The string that resulted from normalization.
     */
    abstract protected function _normalizeString($subject);

    /**
     * Creates a new "could not migrate" exception instance.
     *
     * @since [*next-version*]
     *
     * @param string|Stringable|null $message  The error message, if any.
     * @param int|null               $code     The error code, if any.
     * @param RootException|null     $previous The inner exception for chaining, if any.
     * @param string|Stringable|null $version  The migration version that failed, if any.
     *
     * @return CouldNotMigrateExceptionInterface The created exception.
     */
    abstract protected function _createCouldNotMigrateException(
        $message = null,
        $code = null,
        RootException $previous = null,
        $version = null
    );

    /**
     * Translates a string, and replaces placeholders.
     *
     * @since [*next-version*]
     * @see   sprintf()
     *
     * @param string $string  The format string to translate.
     * @param array  $args    Placeholder values to replace in the string.
     * @param mixed  $context The context for translation.
     *
     * @return string The translated string.
     */
    abstract protected function __($string, $args = [], $context = null);
}