Skip to content
Snippets Groups Projects
Migrations.php 17.4 KiB
Newer Older
<?php

namespace Breyta;

use Breyta\Migration\CreateMigrationTable;
use Breyta\Model;
/**
 * Class Migrations
 *
 * The migration engine that puts all parts together.
 *
 * @package Breyta
 * @author Thomas Flori <thflori@gmail.com>
 */
class Migrations
{
    const INTERNAL_PREFIX = '@breyta/';

    /** The name of the migration table
     * @var string */
    public static $table = 'migrations';

    /** The path to the template for migrations
     * @var string */
    public static $templatePath = __DIR__ . '/../resources/MigrationTemplate.php';

    /** @var \PDO */
    protected $db;
    /** @var string */
    protected $path;

    /** @var array|Model\Migration[] */
    protected $migrations;

    /** @var array|Model\Migration[] */
    protected $missingMigrations = [];

    /** @var array|Model\Statement[] */
    protected $statements = [];

    /** @var AdapterInterface */
    protected $adapter;

    /** @var callable */
    protected $resolver;

    /** @var ProgressInterface */
    protected $progress;

    public function __construct(\PDO $db, string $path, callable $resolver = null, ProgressInterface $progress = null)
    {
        if (!file_exists($path) || !is_dir($path)) {
            throw new \InvalidArgumentException('The path to migrations is not valid');
        }

        // force the error mode to exception
        $db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);

        $this->db = $db;
        $this->path = rtrim($path, '/');
        /** @codeCoverageIgnore the default resolver is a) trivial and b) not testable */
        $this->resolver = $resolver ?? function ($class, ...$args) {
            if ($class === AdapterInterface::class) {
                return new BasicAdapter(...$args);
            }
            return new $class(...$args);
        };

        $this->progress = $progress ?? new CallbackProgress();
    /**
     * Returns the status of the migrations
     *
     * It contains an array of all migrations, the count of migrations that are not migrated yet and an array of
     * migrations that got removed (if files where removed).
     *
     * @return \stdClass
     */
    public function getStatus(): \stdClass
    {
        $this->loadMigrations();

        $status = (object)[
            'migrations' => $this->migrations,
            'count' => count(array_filter($this->migrations, function ($migration) {
                return $migration->status !== 'done';
            })),

        if (count($this->missingMigrations)) {
            $status->missing = $this->missingMigrations;
        }

        return $status;
    /**
     * Migrate all migrations that are not migrated yet
     *
     * @return bool
     */
    public function migrate(): bool
        $this->loadMigrations();

        /** @var Model\Migration[] $migrations */
        $migrations = array_filter($this->migrations, function (Model\Migration $migration) {
            return $migration->status !== 'done';
        return $this->up(...$migrations);
    }

    /**
     * Migrate all migrations to a specific migration or date time
     *
     * $file can either be a relative file name (or a portion matched with `strpos()`) or a date time string to execute
     * all migrations to that time.
     *
     * @param string $file
     * @return bool
     */
    public function migrateTo(string $file)
    {
        $this->loadMigrations();

        $found = false;
        $migrations = [];
        foreach ($this->migrations as $migration) {
            $migrations[] = $migration;
            if (strpos($migration->file, $file) !== false) {
                $found = true;
                break;
            }
        }

        if (!$found && $time = strtotime($file)) {
            $migrations = array_filter($this->migrations, function (Model\Migration $migration) use ($time) {
                $migrationTime = FileHelper::getTimeFromFileName($migration->file);
                return is_null($migrationTime) || $migrationTime <= $time;
            });
        } elseif (!$found) {
            throw new \LogicException('No migration found matching ' . $file);
        }

        /** @var Model\Migration[] $migrations */
        $migrations = array_filter($migrations, function (Model\Migration $migration) {
            return $migration->status !== 'done';
        });

        return $this->up(...$migrations);
    }
    /**
     * Migrate specific migrations
     *
     * @param Model\Migration ...$migrations
     * @return bool
     */
    public function up(Model\Migration ...$migrations)
    {
        $started = microtime(true);
        $this->progress->start((object)[
            'migrations' => $this->migrations,
            'task' => 'migrate',
            'count' => count($migrations),
            'toExecute' => $migrations,
        ]);

        foreach ($migrations as $migration) {
            $this->progress->beforeMigration($migration);
            $this->statements = [];
            $start = microtime(true);
            try {
                $this->db->beginTransaction();
                $class = self::internalClass($migration->file) ??
                         FileHelper::getClassFromFile($this->path . DIRECTORY_SEPARATOR . $migration->file);
                /** @var AbstractMigration $migrationInstance */
                $migrationInstance = call_user_func($this->resolver, $class, $this->getAdapter());
                $migrationInstance->up();
                $this->saveMigration($migration, 'done', microtime(true) - $start);
                $this->db->commit();
            } catch (\PDOException $exception) {
                $this->db->rollBack();
                $this->saveMigration($migration, 'failed', microtime(true) - $start);
                throw $exception;
            $this->progress->afterMigration($migration);
        $this->progress->finish((object)[
            'migrations' => $this->migrations,
            'task' => 'migrate',
            'count' => count($migrations),
            'executed' => $migrations,
            'executionTime' => microtime(true) - $started
        ]);
    /**
     * Revert all migrations that have been migrated
     *
     * @return bool
     */
    public function revert()
    {
        $this->loadMigrations();

        /** @var Model\Migration[] $migrations */
        $migrations = array_filter($this->migrations, function (Model\Migration $migration) {
            return $migration->status === 'done' && !self::isInternal($migration->file);
        });

        return $this->down(...array_reverse($migrations));
    }

    /**
     * Revert all migrations to a specific migration or date time
     *
     * $file can either be a relative file name (or a portion matched with `strpos()`) or a date time string to execute
     * all migrations to that time.
     *
     * **Note:** This will not revert the migration matched the pattern. It is resetting to the state of the database
     * to the state when <file> was executed.
     *
     * @param string $file
     * @return bool
     */
    public function revertTo(string $file)
    {
        $this->loadMigrations();

        $found = false;
        $migrations = [];
        foreach (array_reverse($this->migrations) as $migration) {
            if (strpos($migration->file, $file) !== false) {
                $found = true;
                break;
            }
            $migrations[] = $migration;
        }

        if (!$found && $time = strtotime($file)) {
            $migrations = array_reverse(
                array_filter($this->migrations, function (Model\Migration $migration) use ($time) {
                    $migrationTime = FileHelper::getTimeFromFileName($migration->file);
                    return !is_null($migrationTime) && $migrationTime > $time;
                })
            );
        } elseif (!$found) {
            throw new \LogicException('No migration found matching ' . $file);
        }

        /** @var Model\Migration[] $toExecute */
        $migrations = array_filter($migrations, function (Model\Migration $migration) {
            return $migration->status === 'done' && !self::isInternal($migration->file);
        });

        return $this->down(...$migrations);
    }
    /**
     * Revert specific migrations
     *
     * @param Model\Migration ...$migrations
     * @return bool
     */
    public function down(Model\Migration ...$migrations)
    {
        $started = microtime(true);
        $this->progress->start((object)[
            'migrations' => $this->migrations,
            'task' => 'revert',
            'count' => count($migrations),
            'toExecute' => $migrations,
        ]);

        foreach ($migrations as $migration) {
            $this->progress->beforeMigration($migration);
            $this->statements = $migration->statements;
            $start = microtime(true) - $migration->executionTime;
            try {
                $this->db->beginTransaction();
                $class = self::internalClass($migration->file) ??
                         FileHelper::getClassFromFile($this->path . DIRECTORY_SEPARATOR . $migration->file);
                /** @var AbstractMigration $migrationInstance */
                $migrationInstance = call_user_func($this->resolver, $class, $this->getAdapter());
                $migrationInstance->down();
                $this->saveMigration($migration, 'reverted', microtime(true) - $start);
                $this->db->commit();
            } catch (\PDOException $exception) {
                $this->db->rollBack();
                throw $exception;
            }
            $this->progress->afterMigration($migration);

        $this->progress->finish((object)[
            'migrations' => $this->migrations,
            'task' => 'revert',
            'count' => count($migrations),
            'executed' => $migrations,
            'executionTime' => microtime(true) - $started
        ]);
    /**
     * Creates a migration
     *
     * We recommend StudlyCase naming for PSR2 compatibility. Also the files will get a namespace.
     *
     * Returns the path to the generated migration.
     *
     * @param string $name
    public function createMigration(string $name): string
    {
        static $template;
        if (is_null($template)) {
            $template = file_get_contents(self::$templatePath);
        }

        $path = explode('/', $name);
        $name = array_pop($path);
        $fileName = implode('/', array_merge($path, [date('Y-m-d\TH.i.s\Z') . '_' . $name . '.php']));
        $className = $name;
        $namespace = implode('\\', array_merge(['Migration'], $path));
        $fullPath = $this->path . DIRECTORY_SEPARATOR . $fileName;

        if (!file_exists(dirname($fullPath))) {
            mkdir(dirname($fullPath), umask() ^ 0777, true);
        }

        file_put_contents($fullPath, strtr($template, [
            'NAMESPACE_NAME' => $namespace,
            'CLASS_NAME' => $className,
            'FILE_NAME' => $fileName,
        ]));
    /** @codeCoverageIgnore */
    public function getProgress(): ProgressInterface
    {
        return $this->progress;
    }

    /** @codeCoverageIgnore */
    public function setProgress(ProgressInterface $progress)
    {
        $this->progress = $progress;
    }

    protected function saveMigration(Model\Migration $migration, $status, $executionTime)
    {
        $exists = (bool)$migration->executed;
        $table = self::$table;
        $now = new \DateTime('now', new \DateTimeZone('UTC'));
        $status === 'reverted' ? $migration->reverted = $now : $migration->executed = $now;

        $migration->statements = $this->statements;
        $migration->status = $status;
        $migration->executionTime = $executionTime;

            $this->db->prepare("INSERT INTO {$table}
                (file, executed, status, statements, executionTime) VALUES
                (?, ?, ?, ?, ?)
            ")->execute([
                $migration->file,
                $migration->executed->format('c'),
                $migration->status,
                json_encode($migration->statements),
                $migration->executionTime
            ]);
        } else {
            $this->db->prepare("UPDATE {$table} SET
                executed = ?, reverted = ?, status = ?, statements = ?, executionTime = ?
                WHERE file = ?
            ")->execute([
                $migration->executed->format('c'),
                $migration->reverted ? $migration->reverted->format('c') : null,
                $migration->status,
                json_encode($migration->statements),
                $migration->executionTime,
                $migration->file
            ]);
        }
    protected function loadMigrations()
    {
        if (!$this->migrations) {
            $migrations = $this->findMigrations();

            // get the status of migrations from database
            try {
                $statement = $this->db->query('SELECT * FROM migrations');
                if ($statement) {
                    $statement->setFetchMode(\PDO::FETCH_CLASS, Model\Migration::class);
                    while ($migration = $statement->fetch()) {
                        if (!isset($migrations[$migration->file])) {
                            $this->missingMigrations[] = $migration;
                            continue;
                        }
                        $migrations[$migration->file] = $migration;
                    }
                }
            } catch (\PDOException $exception) {
                // the table does not exist - so nothing to do here
            }
            $this->migrations = array_values($migrations);
        }
    }

    protected function findMigrations(): array
    {
        $migrations = [Model\Migration::createInstance([
            'file' => self::INTERNAL_PREFIX . 'CreateMigrationTable.php',
            'status' => 'new',
        /** @var \SplFileInfo $fileInfo */
        foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->path)) as $fileInfo) {
            if (is_dir($fileInfo->getPathname()) ||
                $fileInfo->getFilename()[0] === '.' ||
                substr($fileInfo->getFilename(), -4) !== '.php'
            ) {
                continue;
            }

            $className = FileHelper::getClassFromFile($fileInfo->getPathname());
            if (!$className) {
                continue;
            }
            require_once $fileInfo->getPathname();
            if (!is_subclass_of($className, AbstractMigration::class)) {
                continue;
            }

            $file = substr($fileInfo->getPathname(), strlen($this->path) + 1);
            $migrations[] = Model\Migration::createInstance([
                'file' => $file,
                'status' => 'new'
        }

        usort($migrations, function ($left, $right) {
            // sort criteria 1: is from breyta
            $leftIsFromBreyta = substr($left->file, 0, 8) === '@breyta/';
            $rightIsFromBreyta = substr($right->file, 0, 8) === '@breyta/';
            if ($leftIsFromBreyta !== $rightIsFromBreyta) {
                return $rightIsFromBreyta - $leftIsFromBreyta;
            }

            $leftBaseName = basename($left->file);
            $rightBaseName = basename($right->file);

            // sort criteria 2: has creation date
            $leftTime = FileHelper::getTimeFromFileName($left->file);
            $rightTime = FileHelper::getTimeFromFileName($right->file);
            if (is_null($leftTime) !== is_null($rightTime)) {
                return is_null($rightTime) - is_null($leftTime);
            }

            // sort criteria 3: by creation date
            if ($leftTime !== $rightTime) {
                return $leftTime - $rightTime;
            // sort criteria 4: alphabetically
            return strcmp($leftBaseName, $rightBaseName);
        });

        // key by identifier...
        $migrations = array_combine(array_map(function ($migration) {
            return $migration->file;
        }, $migrations), $migrations);

        return $migrations;
    }
    protected function executeStatement(Model\Statement $statement)
    {
        $this->progress->beforeExecution($statement);
        $start = microtime(true);
        try {
            $statement->result = $this->db->exec($statement->raw);
            $statement->exception = null;
        } catch (\PDOException $exception) {
            $statement->exception = $exception;
            throw $exception;
        } finally {
            $statement->executionTime = microtime(true) - $start;
            $this->progress->afterExecution($statement);
    protected function getAdapter(): AdapterInterface
    {
        if (!$this->adapter) {
            $this->adapter = call_user_func(
                $this->resolver,
                AdapterInterface::class,
                function (Model\Statement $statement) {
                     $this->statements[] = $statement;
                    $this->executeStatement($statement);
        return $this->adapter;

    protected static function internalClass(string $file): ?string
    {
        return self::isInternal($file) ? 'Breyta\\Migration\\' . substr($file, 8, -4) : null;
    }

    protected static function isInternal(string $file): bool
    {
        return strncmp($file, self::INTERNAL_PREFIX, strlen(self::INTERNAL_PREFIX)) === 0;
    }