summaryrefslogtreecommitdiffstats
path: root/lib/private/DB/MigrationService.php
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/DB/MigrationService.php')
-rw-r--r--lib/private/DB/MigrationService.php401
1 files changed, 401 insertions, 0 deletions
diff --git a/lib/private/DB/MigrationService.php b/lib/private/DB/MigrationService.php
new file mode 100644
index 00000000000..8a4980d1118
--- /dev/null
+++ b/lib/private/DB/MigrationService.php
@@ -0,0 +1,401 @@
+<?php
+/**
+ * @author Thomas Müller <thomas.mueller@tmit.eu>
+ * @author Vincent Petry <pvince81@owncloud.com>
+ *
+ * @copyright Copyright (c) 2017, ownCloud GmbH
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OC\DB;
+
+use OC\IntegrityCheck\Helpers\AppLocator;
+use OC\Migration\SimpleOutput;
+use OCP\AppFramework\QueryException;
+use OCP\IDBConnection;
+use OCP\Migration\IOutput;
+use OCP\Migration\ISchemaMigration;
+use OCP\Migration\ISimpleMigration;
+use OCP\Migration\ISqlMigration;
+use Doctrine\DBAL\Schema\Column;
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\DBAL\Types\Type;
+
+class MigrationService {
+
+ /** @var boolean */
+ private $migrationTableCreated;
+ /** @var array */
+ private $migrations;
+ /** @var IOutput */
+ private $output;
+ /** @var Connection */
+ private $connection;
+ /** @var string */
+ private $appName;
+
+ /**
+ * MigrationService constructor.
+ *
+ * @param $appName
+ * @param IDBConnection $connection
+ * @param AppLocator $appLocator
+ * @param IOutput|null $output
+ * @throws \Exception
+ */
+ function __construct($appName, IDBConnection $connection, IOutput $output = null, AppLocator $appLocator = null) {
+ $this->appName = $appName;
+ $this->connection = $connection;
+ $this->output = $output;
+ if (is_null($this->output)) {
+ $this->output = new SimpleOutput(\OC::$server->getLogger(), $appName);
+ }
+
+ if ($appName === 'core') {
+ $this->migrationsPath = \OC::$SERVERROOT . '/core/Migrations';
+ $this->migrationsNamespace = 'OC\\Migrations';
+ } else {
+ if (is_null($appLocator)) {
+ $appLocator = new AppLocator();
+ }
+ $appPath = $appLocator->getAppPath($appName);
+ $this->migrationsPath = "$appPath/appinfo/Migrations";
+ $this->migrationsNamespace = "OCA\\$appName\\Migrations";
+ }
+
+ if (!is_dir($this->migrationsPath)) {
+ if (!mkdir($this->migrationsPath)) {
+ throw new \Exception("Could not create migration folder \"{$this->migrationsPath}\"");
+ };
+ }
+ }
+
+ private static function requireOnce($file) {
+ require_once $file;
+ }
+
+ /**
+ * Returns the name of the app for which this migration is executed
+ *
+ * @return string
+ */
+ public function getApp() {
+ return $this->appName;
+ }
+
+ /**
+ * @return bool
+ * @codeCoverageIgnore - this will implicitly tested on installation
+ */
+ private function createMigrationTable() {
+ if ($this->migrationTableCreated) {
+ return false;
+ }
+
+ if ($this->connection->tableExists('migrations')) {
+ $this->migrationTableCreated = true;
+ return false;
+ }
+
+ $tableName = $this->connection->getPrefix() . 'migrations';
+ $tableName = $this->connection->getDatabasePlatform()->quoteIdentifier($tableName);
+
+ $columns = [
+ 'app' => new Column($this->connection->getDatabasePlatform()->quoteIdentifier('app'), Type::getType('string'), ['length' => 255]),
+ 'version' => new Column($this->connection->getDatabasePlatform()->quoteIdentifier('version'), Type::getType('string'), ['length' => 255]),
+ ];
+ $table = new Table($tableName, $columns);
+ $table->setPrimaryKey([
+ $this->connection->getDatabasePlatform()->quoteIdentifier('app'),
+ $this->connection->getDatabasePlatform()->quoteIdentifier('version')]);
+ $this->connection->getSchemaManager()->createTable($table);
+
+ $this->migrationTableCreated = true;
+
+ return true;
+ }
+
+ /**
+ * Returns all versions which have already been applied
+ *
+ * @return string[]
+ * @codeCoverageIgnore - no need to test this
+ */
+ public function getMigratedVersions() {
+ $this->createMigrationTable();
+ $qb = $this->connection->getQueryBuilder();
+
+ $qb->select('version')
+ ->from('migrations')
+ ->where($qb->expr()->eq('app', $qb->createNamedParameter($this->getApp())))
+ ->orderBy('version');
+
+ $result = $qb->execute();
+ $rows = $result->fetchAll(\PDO::FETCH_COLUMN);
+ $result->closeCursor();
+
+ return $rows;
+ }
+
+ /**
+ * Returns all versions which are available in the migration folder
+ *
+ * @return array
+ */
+ public function getAvailableVersions() {
+ $this->ensureMigrationsAreLoaded();
+ return array_keys($this->migrations);
+ }
+
+ protected function findMigrations() {
+ $directory = realpath($this->migrationsPath);
+ $iterator = new \RegexIterator(
+ new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::LEAVES_ONLY
+ ),
+ '#^.+\\/Version[^\\/]{1,255}\\.php$#i',
+ \RegexIterator::GET_MATCH);
+
+ $files = array_keys(iterator_to_array($iterator));
+ uasort($files, function ($a, $b) {
+ return (basename($a) < basename($b)) ? -1 : 1;
+ });
+
+ $migrations = [];
+
+ foreach ($files as $file) {
+ static::requireOnce($file);
+ $className = basename($file, '.php');
+ $version = (string) substr($className, 7);
+ if ($version === '0') {
+ throw new \InvalidArgumentException(
+ "Cannot load a migrations with the name '$version' because it is a reserved number"
+ );
+ }
+ $migrations[$version] = sprintf('%s\\%s', $this->migrationsNamespace, $className);
+ }
+
+ return $migrations;
+ }
+
+ /**
+ * @param string $to
+ */
+ private function getMigrationsToExecute($to) {
+ $knownMigrations = $this->getMigratedVersions();
+ $availableMigrations = $this->getAvailableVersions();
+
+ $toBeExecuted = [];
+ foreach ($availableMigrations as $v) {
+ if ($to !== 'latest' && $v > $to) {
+ continue;
+ }
+ if ($this->shallBeExecuted($v, $knownMigrations)) {
+ $toBeExecuted[] = $v;
+ }
+ }
+
+ return $toBeExecuted;
+ }
+
+ /**
+ * @param string[] $knownMigrations
+ */
+ private function shallBeExecuted($m, $knownMigrations) {
+ if (in_array($m, $knownMigrations)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @param string $version
+ */
+ private function markAsExecuted($version) {
+ $this->connection->insertIfNotExist('*PREFIX*migrations', [
+ 'app' => $this->appName,
+ 'version' => $version
+ ]);
+ }
+
+ /**
+ * Returns the name of the table which holds the already applied versions
+ *
+ * @return string
+ */
+ public function getMigrationsTableName() {
+ return $this->connection->getPrefix() . 'migrations';
+ }
+
+ /**
+ * Returns the namespace of the version classes
+ *
+ * @return string
+ */
+ public function getMigrationsNamespace() {
+ return $this->migrationsNamespace;
+ }
+
+ /**
+ * Returns the directory which holds the versions
+ *
+ * @return string
+ */
+ public function getMigrationsDirectory() {
+ return $this->migrationsPath;
+ }
+
+ /**
+ * Return the explicit version for the aliases; current, next, prev, latest
+ *
+ * @param string $alias
+ * @return mixed|null|string
+ */
+ public function getMigration($alias) {
+ switch($alias) {
+ case 'current':
+ return $this->getCurrentVersion();
+ case 'next':
+ return $this->getRelativeVersion($this->getCurrentVersion(), 1);
+ case 'prev':
+ return $this->getRelativeVersion($this->getCurrentVersion(), -1);
+ case 'latest':
+ $this->ensureMigrationsAreLoaded();
+
+ return @end($this->getAvailableVersions());
+ }
+ return '0';
+ }
+
+ /**
+ * @param string $version
+ * @param int $delta
+ * @return null|string
+ */
+ private function getRelativeVersion($version, $delta) {
+ $this->ensureMigrationsAreLoaded();
+
+ $versions = $this->getAvailableVersions();
+ array_unshift($versions, 0);
+ $offset = array_search($version, $versions);
+ if ($offset === false || !isset($versions[$offset + $delta])) {
+ // Unknown version or delta out of bounds.
+ return null;
+ }
+
+ return (string) $versions[$offset + $delta];
+ }
+
+ /**
+ * @return string
+ */
+ private function getCurrentVersion() {
+ $m = $this->getMigratedVersions();
+ if (count($m) === 0) {
+ return '0';
+ }
+ return @end(array_values($m));
+ }
+
+ /**
+ * @return string
+ */
+ private function getClass($version) {
+ $this->ensureMigrationsAreLoaded();
+
+ if (isset($this->migrations[$version])) {
+ return $this->migrations[$version];
+ }
+
+ throw new \InvalidArgumentException("Version $version is unknown.");
+ }
+
+ /**
+ * Allows to set an IOutput implementation which is used for logging progress and messages
+ *
+ * @param IOutput $output
+ */
+ public function setOutput(IOutput $output) {
+ $this->output = $output;
+ }
+
+ /**
+ * Applies all not yet applied versions up to $to
+ *
+ * @param string $to
+ */
+ public function migrate($to = 'latest') {
+ // read known migrations
+ $toBeExecuted = $this->getMigrationsToExecute($to);
+ foreach ($toBeExecuted as $version) {
+ $this->executeStep($version);
+ }
+ }
+
+ /**
+ * @param string $version
+ * @return mixed
+ * @throws \Exception
+ */
+ protected function createInstance($version) {
+ $class = $this->getClass($version);
+ try {
+ $s = \OC::$server->query($class);
+ } catch (QueryException $e) {
+ if (class_exists($class)) {
+ $s = new $class();
+ } else {
+ throw new \Exception("Migration step '$class' is unknown");
+ }
+ }
+
+ return $s;
+ }
+
+ /**
+ * Executes one explicit version
+ *
+ * @param string $version
+ */
+ public function executeStep($version) {
+
+ // FIXME our interface
+ $instance = $this->createInstance($version);
+ if ($instance instanceof ISimpleMigration) {
+ $instance->run($this->output);
+ }
+ if ($instance instanceof ISqlMigration) {
+ $sqls = $instance->sql($this->connection);
+ foreach ($sqls as $s) {
+ $this->connection->executeQuery($s);
+ }
+ }
+ if ($instance instanceof ISchemaMigration) {
+ $toSchema = $this->connection->createSchema();
+ $instance->changeSchema($toSchema, ['tablePrefix' => $this->connection->getPrefix()]);
+ $this->connection->migrateToSchema($toSchema);
+ }
+ $this->markAsExecuted($version);
+ }
+
+ private function ensureMigrationsAreLoaded() {
+ if (empty($this->migrations)) {
+ $this->migrations = $this->findMigrations();
+ }
+ }
+}