Skip to content

Commit

Permalink
Implemented database migration tools.
Browse files Browse the repository at this point in the history
  • Loading branch information
bbankowski committed Nov 4, 2019
1 parent 99e34f5 commit 6debdb2
Show file tree
Hide file tree
Showing 8 changed files with 366 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -9,6 +9,7 @@ Support for PHP 5.6 is dropped. Minimal PHP version required is 7.2.
Enhancements:
* [Utilities] Added `Arrays.getDuplicates()`, `Arrays.getDuplicatesAssoc()`.
* [Utilities] Added `FluentArray.getDuplicates()`, `FluentArray.getDuplicatesAssoc()`.
* [Core] Added database migration tools (migrate:run and migrate:generate).

Release 1.7.0
--------
Expand Down
65 changes: 65 additions & 0 deletions bin/MigrationGeneratorCommand.php
@@ -0,0 +1,65 @@
<?php
/*
* Copyright (c) Ouzo contributors, http://ouzoframework.org
* This file is made available under the MIT License (view the LICENSE file for more information).
*/

namespace Command;


use Ouzo\Utilities\Clock;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class MigrationGeneratorCommand extends Command
{
/* @var InputInterface */
private $input;
/* @var OutputInterface */
private $output;

public function configure()
{
$this->setName('migration:generate')
->addArgument('name', InputArgument::REQUIRED, 'Migration name')
->addArgument('dir', InputArgument::OPTIONAL, 'Migration directory');
}

public function execute(InputInterface $input, OutputInterface $output)
{
$this->input = $input;
$this->output = $output;

$name = $this->input->getArgument('name');
$dir = $this->input->getArgument('dir');
$dir = $dir ? $dir . '/' : '';
$clock = Clock::now();
$date = $clock->format('Ymd');
$time = $clock->getTimestamp();
$path = "{$dir}{$date}{$time}_{$name}Migration.php";

$this->output->writeln("Migration file name: <info>{$path}</info>");

$data = <<<MIGRATION
<?php
use Ouzo\Db;
use Ouzo\Migration;
class {$name}Migration extends Migration
{
public function run(Db \$db)

This comment has been minimized.

Copy link
@piotrooo

piotrooo Nov 5, 2019

Member

@bbankowski what is a \$db?

{
\$db->execute("SELECT 1");
}
}
MIGRATION;

file_put_contents($path, $data);

$this->output->writeln("<comment>Generating...</comment> <info>DONE</info>");
}
}
223 changes: 223 additions & 0 deletions bin/MigrationRunnerCommand.php
@@ -0,0 +1,223 @@
<?php
/*
* Copyright (c) Ouzo contributors, http://ouzoframework.org
* This file is made available under the MIT License (view the LICENSE file for more information).
*/

namespace Command;

use Exception;
use Ouzo\Config;
use Ouzo\Db;
use Ouzo\Db\TransactionalProxy;
use Ouzo\Migration;
use Ouzo\MigrationFailedException;
use Ouzo\SchemaMigration;
use Ouzo\Utilities\Arrays;
use Ouzo\Utilities\Clock;
use Ouzo\Utilities\Functions;
use Ouzo\Utilities\Objects;
use Ouzo\Utilities\Strings;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;

class MigrationRunnerCommand extends Command
{
/* @var InputInterface */
private $input;
/* @var OutputInterface */
private $output;
/* @var bool */
private $commitEarly;
/* @var bool */
private $force;
/* @var bool */
private $init;
/* @var string */
private $dir;
/* @var bool */
private $reset;

public function configure()
{
$this->setName('migration:run')
->addOption('commit_early', 'c', InputOption::VALUE_NONE, 'Commit after each migration')
->addOption('reset', 'r', InputOption::VALUE_NONE, 'Remove all previous migrations')
->addOption('init', 'i', InputOption::VALUE_NONE, 'Add schema_migrations table')
->addOption('dir', 'd', InputOption::VALUE_REQUIRED, 'Directories with migrations (separated by comma)')

This comment has been minimized.

Copy link
@piotrooo

piotrooo Nov 5, 2019

Member

I suggest use InputOption::VALUE_IS_ARRAY option type, for the multiple values. We can combine InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY.

->addOption('force', 'f', InputOption::VALUE_NONE, 'Force confirmation');
}

public function execute(InputInterface $input, OutputInterface $output)
{
$this->input = $input;
$this->output = $output;
$this->commitEarly = $this->input->getOption('commit_early') ? true : false;

This comment has been minimized.

Copy link
@piotrooo

piotrooo Nov 5, 2019

Member

@bbankowski all options with VALUE_NONE are by default booleans.

$this->force = $this->input->getOption('force') ? true : false;
$this->init = $this->input->getOption('init') ? true : false;
$this->dir = $this->input->getOption('dir') ?: '';
$this->reset = $this->input->getOption('reset') ? true : false;

$this->migrate();
}

private function migrate()
{
$dbname = Config::getValue('db', 'dbname');

$this->output->writeln('=======================================================');
$this->output->writeln(" Database: " . $dbname);
$this->output->writeln(" Commit early = " . Objects::toString($this->commitEarly));
$this->output->writeln(" Directory = " . $this->dir);
$this->output->writeln(" Initialize = " . Objects::toString($this->commitEarly));
$this->output->writeln(" Force = " . Objects::toString($this->init));
$this->output->writeln(" Reset = " . Objects::toString($this->reset));
$this->output->writeln('=======================================================');
$this->output->writeln('');

$db = $this->connectToDatabase($dbname);
$this->initMigrations($db);
$this->resetMigrations();

$this->output->writeln("\nMigrations to apply:");
$migrations = $this->loadMigrations();
$this->output->writeln('');

if (empty($migrations)) {
$this->output->writeln('None. Bye!');
return 0;
}

if (!$this->force) {
$helper = $this->getHelper('question');
$question = new ConfirmationQuestion('Do you want to continue? [y/n] ', false);
$question->setMaxAttempts(1);
if (!$helper->ask($this->input, $this->output, $question)) {
$this->output->writeln('What a bummer. Bye!');
return 0;
}
}

try {
$self = $this->commitEarly ? $this : TransactionalProxy::newInstance($this);
$self->runAll($db, $migrations);
$this->output->writeln("\n\n<info>That's all. Bye!</info>");
return 0;
} catch (MigrationFailedException $ex) {
$this->output->writeln("\n<error>Error</error>");
$this->output->writeln("Could not apply migration {$ex->getClassName()} version {$ex->getVersion()}: {$ex->getMessage()}");
$this->output->writeln($ex->getPrevious()->getTraceAsString());
return 1;
}
}

public function runAll(Db $db, array $migrations): void
{
$progressBar = $this->createProgressBar(count($migrations));

foreach ($migrations as $migration) {
list($className, $version) = $migration;
$progressBar->setMessage("[$version] $className");
try {
$db->runInTransaction(function () use ($className, $version, $db) {
$this->runSingleMigration($db, $className, $version);
});
} catch (Exception $ex) {
throw new MigrationFailedException($ex, $className, $version);
}
$progressBar->advance();
}

$progressBar->finish();
}

private function runSingleMigration(Db $db, $className, $version): void
{
/** @var Migration $migration */
$migration = new $className;
$migration->run($db);
SchemaMigration::create(['version' => $version, 'applied_at' => Clock::nowAsString()]);
}

private function loadMigrations(): array
{
$versions = Arrays::map(SchemaMigration::all(), Functions::extract()->version);

$migrations = [];
$dirs = explode(',', $this->dir);
foreach ($dirs as $dir) {
$migrations = array_merge($migrations, $this->loadMigrationsFromDir($dir, $versions));
}
return $migrations;
}

private function createProgressBar(int $max): ProgressBar
{
ProgressBar::setFormatDefinition(
'normal',
"<info>Applying migration</info> <fg=cyan>%message%</>\n%current%/%max% [%bar%] %percent:3s%%"
);
$progress = new ProgressBar($this->output, $max);
$progress->setMessage('');
$progress->start();
return $progress;
}

private function initMigrations(Db $db): void

This comment has been minimized.

Copy link
@piotrooo

piotrooo Nov 5, 2019

Member

I think that init should be a separated command.

{
if ($this->init) {
$this->output->write("<info>Initializing migrations... </info>");
$db->execute("CREATE TABLE schema_migrations(
id SERIAL PRIMARY KEY,
version TEXT,
applied_at TIMESTAMP
)");
$this->output->writeln('<comment>DONE</comment>');
}
}

private function connectToDatabase($dbname): Db
{
$this->output->write("<info>Connecting to db {$dbname}... </info>");
$db = Db::getInstance();
$this->output->writeln('<comment>DONE</comment>');
return $db;
}

private function loadMigrationsFromDir(string $dir, array $versions): array
{
if (empty($dir)) {
return [];
}
if (!file_exists($dir)) {
throw new Exception("Migration directory `{$dir}` does not exist.");
}
$migrations = [];
$files = scandir($dir, 0);
for ($i = 2; $i < count($files); $i++) {
$file = $files[$i];
$path = $dir . '/' . $file;
$version = substr($file, 0, strpos($file, '_'));
if (is_file($path) && !in_array($version, $versions)) {
include_once($path);
$className = Strings::removeSuffix(substr($file, strpos($file, '_') + 1), '.php');
$this->output->writeln(" [$version] $className");
$migrations[] = [$className, $version];
}
}
return $migrations;
}

private function resetMigrations(): void

This comment has been minimized.

Copy link
@piotrooo

piotrooo Nov 5, 2019

Member

I think that reset migrations should be a separated command.

{
if ($this->reset) {
$this->output->write("<info>Removing all migrations... </info>");
SchemaMigration::where()->deleteAll();
$this->output->writeln('<comment>DONE</comment>');
}
}
}
3 changes: 2 additions & 1 deletion composer.json
Expand Up @@ -27,7 +27,8 @@
"Ouzo\\": [
"src/Ouzo/Core",
"src/Ouzo/Goodies",
"src/Ouzo/Inject"
"src/Ouzo/Inject",
"src/Ouzo/Migrations"
],
"Command\\": "bin/"
}
Expand Down
11 changes: 11 additions & 0 deletions run_console.sh
@@ -0,0 +1,11 @@
#!/bin/bash

source variables

ARGS=""
if [ $# -ne 0 ]
then
ARGS=$@
fi
docker run --rm -e HOME=/var/www -e GIT_COMMITTER_NAME=docker -e GIT_COMMITTER_EMAIL=docker@docker \
-u `id -u $USER` -v $(pwd):/var/www/ -t $DOCKER_WEB_NAME /var/www/console $ARGS
12 changes: 12 additions & 0 deletions src/Ouzo/Migrations/Migration.php
@@ -0,0 +1,12 @@
<?php
/*
* Copyright (c) Ouzo contributors, http://ouzoframework.org
* This file is made available under the MIT License (view the LICENSE file for more information).
*/

namespace Ouzo;

abstract class Migration
{
public abstract function run(Db $db);
}
35 changes: 35 additions & 0 deletions src/Ouzo/Migrations/MigrationFailedException.php
@@ -0,0 +1,35 @@
<?php
/*
* Copyright (c) Ouzo contributors, http://ouzoframework.org
* This file is made available under the MIT License (view the LICENSE file for more information).
*/

namespace Ouzo;

use Exception;

class MigrationFailedException extends Exception
{
/** @var Exception */
private $exception;
private $className;
private $version;

public function __construct(Exception $exception, $className, $version)
{
parent::__construct($exception->getMessage(), $exception->getCode(), $exception);
$this->exception = $exception;
$this->className = $className;
$this->version = $version;
}

public function getClassName()
{
return $this->className;
}

public function getVersion()
{
return $this->version;
}
}
17 changes: 17 additions & 0 deletions src/Ouzo/Migrations/SchemaMigration.php
@@ -0,0 +1,17 @@
<?php
/*
* Copyright (c) Ouzo contributors, http://ouzoframework.org
* This file is made available under the MIT License (view the LICENSE file for more information).
*/

namespace Ouzo;

class SchemaMigration extends Model
{
public function __construct($attributes = [])
{
parent::__construct([
'attributes' => $attributes,
'fields' => ['version', 'applied_at']]);
}
}

1 comment on commit 6debdb2

@piotrooo
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO we do a lot of things in the command. I'm pretty sure we should extract some code to e.g.: runners, output decorators etc. We should treat a command as a controller which only dispatchs/delegates action to "backend" objects.

Please sign in to comment.