1<?php 2/** 3 * @author Thomas Müller <thomas.mueller@tmit.eu> 4 * @author Vincent Petry <pvince81@owncloud.com> 5 * 6 * @copyright Copyright (c) 2018, ownCloud GmbH 7 * @license AGPL-3.0 8 * 9 * This code is free software: you can redistribute it and/or modify 10 * it under the terms of the GNU Affero General Public License, version 3, 11 * as published by the Free Software Foundation. 12 * 13 * This program is distributed in the hope that it will be useful, 14 * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 * GNU Affero General Public License for more details. 17 * 18 * You should have received a copy of the GNU Affero General Public License, version 3, 19 * along with this program. If not, see <http://www.gnu.org/licenses/> 20 * 21 */ 22 23namespace OC\DB; 24 25use OC\IntegrityCheck\Helpers\AppLocator; 26use OC\Migration\SimpleOutput; 27use OCP\AppFramework\QueryException; 28use OCP\IDBConnection; 29use OCP\Migration\IOutput; 30use OCP\Migration\ISchemaMigration; 31use OCP\Migration\ISimpleMigration; 32use OCP\Migration\ISqlMigration; 33use Doctrine\DBAL\Schema\Column; 34use Doctrine\DBAL\Schema\Table; 35use Doctrine\DBAL\Types\Type; 36use OCP\ILogger; 37 38class MigrationService { 39 40 /** @var boolean */ 41 private $migrationTableCreated; 42 /** @var array */ 43 private $migrations; 44 /** @var IOutput */ 45 private $output; 46 /** @var Connection */ 47 private $connection; 48 /** @var string */ 49 private $appName; 50 /** @var ILogger */ 51 private $logger; 52 /** @var string */ 53 private $migrationsPath; 54 /** @var string */ 55 private $migrationsNamespace; 56 57 /** 58 * MigrationService constructor. 59 * 60 * @param $appName 61 * @param IDBConnection $connection 62 * @param IOutput|null $output 63 * @param AppLocator $appLocator 64 * @param ILogger|null $logger 65 * @throws \OC\NeedsUpdateException 66 */ 67 public function __construct( 68 $appName, 69 IDBConnection $connection, 70 IOutput $output = null, 71 AppLocator $appLocator = null, 72 ILogger $logger = null 73 ) { 74 $this->appName = $appName; 75 $this->connection = $connection; 76 $this->output = $output; 77 if ($this->output === null) { 78 $this->output = new SimpleOutput(\OC::$server->getLogger(), $appName); 79 } 80 $this->logger = $logger; 81 if ($this->logger === null) { 82 $this->logger = \OC::$server->getLogger(); 83 } 84 85 if ($appName === 'core') { 86 $this->migrationsPath = \OC::$SERVERROOT . '/core/Migrations'; 87 $this->migrationsNamespace = 'OC\\Migrations'; 88 } else { 89 if ($appLocator === null) { 90 $appLocator = new AppLocator(); 91 } 92 $appPath = $appLocator->getAppPath($appName); 93 $this->migrationsPath = "$appPath/appinfo/Migrations"; 94 $this->migrationsNamespace = "OCA\\$appName\\Migrations"; 95 } 96 97 if (!\is_dir($this->migrationsPath)) { 98 if (!\mkdir($this->migrationsPath)) { 99 throw new \Exception("Could not create migration folder \"{$this->migrationsPath}\""); 100 } 101 } 102 103 // load the app so that app code can be used during migrations 104 \OC_App::loadApp($this->appName, false); 105 } 106 107 private static function requireOnce($file) { 108 require_once $file; 109 } 110 111 /** 112 * Returns the name of the app for which this migration is executed 113 * 114 * @return string 115 */ 116 public function getApp() { 117 return $this->appName; 118 } 119 120 /** 121 * @return bool 122 * @codeCoverageIgnore - this will implicitly tested on installation 123 */ 124 private function createMigrationTable() { 125 if ($this->migrationTableCreated) { 126 return false; 127 } 128 129 if ($this->connection->tableExists('migrations')) { 130 $this->migrationTableCreated = true; 131 return false; 132 } 133 134 $tableName = $this->connection->getPrefix() . 'migrations'; 135 $tableName = $this->connection->getDatabasePlatform()->quoteIdentifier($tableName); 136 137 $columns = [ 138 // Length = max indexable char length - length of other columns = 191 - 14 139 'app' => new Column($this->connection->getDatabasePlatform()->quoteIdentifier('app'), Type::getType('string'), ['length' => 177]), 140 // Datetime string. Eg: 20172605104128 141 'version' => new Column($this->connection->getDatabasePlatform()->quoteIdentifier('version'), Type::getType('string'), ['length' => 14]), 142 ]; 143 $table = new Table($tableName, $columns); 144 $table->setPrimaryKey([ 145 $this->connection->getDatabasePlatform()->quoteIdentifier('app'), 146 $this->connection->getDatabasePlatform()->quoteIdentifier('version')]); 147 $this->connection->getSchemaManager()->createTable($table); 148 149 $this->migrationTableCreated = true; 150 151 return true; 152 } 153 154 /** 155 * Returns all versions which have already been applied 156 * 157 * @return string[] 158 * @codeCoverageIgnore - no need to test this 159 */ 160 public function getMigratedVersions() { 161 $this->createMigrationTable(); 162 $qb = $this->connection->getQueryBuilder(); 163 164 $qb->select('version') 165 ->from('migrations') 166 ->where($qb->expr()->eq('app', $qb->createNamedParameter($this->getApp()))) 167 ->orderBy('version'); 168 169 $result = $qb->execute(); 170 $rows = $result->fetchAll(\PDO::FETCH_COLUMN); 171 $result->closeCursor(); 172 173 return $rows; 174 } 175 176 /** 177 * Returns all versions which are available in the migration folder 178 * 179 * @return array 180 */ 181 public function getAvailableVersions() { 182 $this->ensureMigrationsAreLoaded(); 183 return \array_keys($this->migrations); 184 } 185 186 protected function findMigrations() { 187 $directory = \realpath($this->migrationsPath); 188 $iterator = new \RegexIterator( 189 new \RecursiveIteratorIterator( 190 new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), 191 \RecursiveIteratorIterator::LEAVES_ONLY 192 ), 193 '#^.+\\/Version[^\\/]{1,255}\\.php$#i', 194 \RegexIterator::GET_MATCH 195 ); 196 197 $files = \array_keys(\iterator_to_array($iterator)); 198 \uasort($files, function ($a, $b) { 199 return (\basename($a) < \basename($b)) ? -1 : 1; 200 }); 201 202 $migrations = []; 203 204 foreach ($files as $file) { 205 static::requireOnce($file); 206 $className = \basename($file, '.php'); 207 $version = (string) \substr($className, 7); 208 if ($version === '0') { 209 throw new \InvalidArgumentException( 210 "Cannot load a migrations with the name '$version' because it is a reserved number" 211 ); 212 } 213 $migrations[$version] = \sprintf('%s\\%s', $this->migrationsNamespace, $className); 214 } 215 216 return $migrations; 217 } 218 219 /** 220 * @param string $to 221 * @return array 222 */ 223 private function getMigrationsToExecute($to) { 224 $knownMigrations = $this->getMigratedVersions(); 225 $availableMigrations = $this->getAvailableVersions(); 226 227 $toBeExecuted = []; 228 foreach ($availableMigrations as $v) { 229 if ($to !== 'latest' && $v > $to) { 230 continue; 231 } 232 if ($this->shallBeExecuted($v, $knownMigrations)) { 233 $toBeExecuted[] = $v; 234 } 235 } 236 237 return $toBeExecuted; 238 } 239 240 /** 241 * @param string $m 242 * @param string[] $knownMigrations 243 * @return bool 244 */ 245 private function shallBeExecuted($m, $knownMigrations) { 246 if (\in_array($m, $knownMigrations)) { 247 return false; 248 } 249 250 return true; 251 } 252 253 /** 254 * @param string $version 255 */ 256 private function markAsExecuted($version) { 257 $this->connection->insertIfNotExist('*PREFIX*migrations', [ 258 'app' => $this->appName, 259 'version' => $version 260 ]); 261 } 262 263 /** 264 * Returns the name of the table which holds the already applied versions 265 * 266 * @return string 267 */ 268 public function getMigrationsTableName() { 269 return $this->connection->getPrefix() . 'migrations'; 270 } 271 272 /** 273 * Returns the namespace of the version classes 274 * 275 * @return string 276 */ 277 public function getMigrationsNamespace() { 278 return $this->migrationsNamespace; 279 } 280 281 /** 282 * Returns the directory which holds the versions 283 * 284 * @return string 285 */ 286 public function getMigrationsDirectory() { 287 return $this->migrationsPath; 288 } 289 290 /** 291 * Return the explicit version for the aliases; current, next, prev, latest 292 * 293 * @param string $alias 294 * @return mixed|null|string 295 */ 296 public function getMigration($alias) { 297 switch ($alias) { 298 case 'current': 299 return $this->getCurrentVersion(); 300 case 'next': 301 return $this->getRelativeVersion($this->getCurrentVersion(), 1); 302 case 'prev': 303 return $this->getRelativeVersion($this->getCurrentVersion(), -1); 304 case 'latest': 305 $this->ensureMigrationsAreLoaded(); 306 307 return @\end($this->getAvailableVersions()); 308 } 309 return '0'; 310 } 311 312 /** 313 * @param string $version 314 * @param int $delta 315 * @return null|string 316 */ 317 private function getRelativeVersion($version, $delta) { 318 $this->ensureMigrationsAreLoaded(); 319 320 $versions = $this->getAvailableVersions(); 321 \array_unshift($versions, 0); 322 $offset = \array_search($version, $versions); 323 if ($offset === false || !isset($versions[$offset + $delta])) { 324 // Unknown version or delta out of bounds. 325 return null; 326 } 327 328 return (string) $versions[$offset + $delta]; 329 } 330 331 /** 332 * @return string 333 */ 334 private function getCurrentVersion() { 335 $m = $this->getMigratedVersions(); 336 if (\count($m) === 0) { 337 return '0'; 338 } 339 return @\end(\array_values($m)); 340 } 341 342 /** 343 * @param string $version 344 * @return string 345 */ 346 private function getClass($version) { 347 $this->ensureMigrationsAreLoaded(); 348 349 if (isset($this->migrations[$version])) { 350 return $this->migrations[$version]; 351 } 352 353 throw new \InvalidArgumentException("Version $version is unknown."); 354 } 355 356 /** 357 * Allows to set an IOutput implementation which is used for logging progress and messages 358 * 359 * @param IOutput $output 360 */ 361 public function setOutput(IOutput $output) { 362 $this->output = $output; 363 } 364 365 /** 366 * Applies all not yet applied versions up to $to 367 * 368 * @param string $to 369 */ 370 public function migrate($to = 'latest') { 371 // read known migrations 372 $toBeExecuted = $this->getMigrationsToExecute($to); 373 foreach ($toBeExecuted as $version) { 374 $this->executeStep($version); 375 } 376 } 377 378 /** 379 * @param string $version 380 * @return mixed 381 * @throws \Exception 382 */ 383 protected function createInstance($version) { 384 $class = $this->getClass($version); 385 try { 386 $s = \OC::$server->query($class); 387 } catch (QueryException $e) { 388 if (\class_exists($class)) { 389 $s = new $class(); 390 } else { 391 throw new \Exception("Migration step '$class' is unknown"); 392 } 393 } 394 395 return $s; 396 } 397 398 /** 399 * Executes one explicit version 400 * 401 * @param string $version 402 */ 403 public function executeStep($version) { 404 $this->logger->debug("Migrations: starting $version from app {$this->appName}", ['app' => 'core']); 405 $instance = $this->createInstance($version); 406 if ($instance instanceof ISimpleMigration) { 407 $instance->run($this->output); 408 } 409 if ($instance instanceof ISqlMigration) { 410 $sqls = $instance->sql($this->connection); 411 if (\is_array($sqls)) { 412 foreach ($sqls as $s) { 413 $this->connection->executeQuery($s); 414 } 415 } 416 } 417 if ($instance instanceof ISchemaMigration) { 418 $toSchema = $this->connection->createSchema(); 419 $instance->changeSchema($toSchema, ['tablePrefix' => $this->connection->getPrefix()]); 420 $this->connection->migrateToSchema($toSchema); 421 } 422 $this->markAsExecuted($version); 423 $this->logger->debug("Migrations: completed $version from app {$this->appName}", ['app' => 'core']); 424 } 425 426 private function ensureMigrationsAreLoaded() { 427 if (empty($this->migrations)) { 428 $this->migrations = $this->findMigrations(); 429 } 430 } 431} 432