MigrationService.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. <?php
  2. /**
  3. * @copyright Copyright (c) 2017 Joas Schilling <coding@schilljs.com>
  4. * @copyright Copyright (c) 2017, ownCloud GmbH
  5. *
  6. * @author Joas Schilling <coding@schilljs.com>
  7. *
  8. * @license AGPL-3.0
  9. *
  10. * This code is free software: you can redistribute it and/or modify
  11. * it under the terms of the GNU Affero General Public License, version 3,
  12. * as published by the Free Software Foundation.
  13. *
  14. * This program is distributed in the hope that it will be useful,
  15. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. * GNU Affero General Public License for more details.
  18. *
  19. * You should have received a copy of the GNU Affero General Public License, version 3,
  20. * along with this program. If not, see <http://www.gnu.org/licenses/>
  21. *
  22. */
  23. namespace OC\DB;
  24. use Doctrine\DBAL\Platforms\OraclePlatform;
  25. use Doctrine\DBAL\Platforms\PostgreSqlPlatform;
  26. use Doctrine\DBAL\Schema\Index;
  27. use Doctrine\DBAL\Schema\Schema;
  28. use Doctrine\DBAL\Schema\SchemaException;
  29. use Doctrine\DBAL\Schema\Sequence;
  30. use Doctrine\DBAL\Schema\Table;
  31. use OC\App\InfoParser;
  32. use OC\IntegrityCheck\Helpers\AppLocator;
  33. use OC\Migration\SimpleOutput;
  34. use OCP\AppFramework\App;
  35. use OCP\AppFramework\QueryException;
  36. use OCP\IDBConnection;
  37. use OCP\Migration\IMigrationStep;
  38. use OCP\Migration\IOutput;
  39. use Doctrine\DBAL\Types\Type;
  40. class MigrationService {
  41. /** @var boolean */
  42. private $migrationTableCreated;
  43. /** @var array */
  44. private $migrations;
  45. /** @var IOutput */
  46. private $output;
  47. /** @var Connection */
  48. private $connection;
  49. /** @var string */
  50. private $appName;
  51. /** @var bool */
  52. private $checkOracle;
  53. /**
  54. * MigrationService constructor.
  55. *
  56. * @param $appName
  57. * @param IDBConnection $connection
  58. * @param AppLocator $appLocator
  59. * @param IOutput|null $output
  60. * @throws \Exception
  61. */
  62. public function __construct($appName, IDBConnection $connection, IOutput $output = null, AppLocator $appLocator = null) {
  63. $this->appName = $appName;
  64. $this->connection = $connection;
  65. $this->output = $output;
  66. if (null === $this->output) {
  67. $this->output = new SimpleOutput(\OC::$server->getLogger(), $appName);
  68. }
  69. if ($appName === 'core') {
  70. $this->migrationsPath = \OC::$SERVERROOT . '/core/Migrations';
  71. $this->migrationsNamespace = 'OC\\Core\\Migrations';
  72. $this->checkOracle = true;
  73. } else {
  74. if (null === $appLocator) {
  75. $appLocator = new AppLocator();
  76. }
  77. $appPath = $appLocator->getAppPath($appName);
  78. $namespace = App::buildAppNamespace($appName);
  79. $this->migrationsPath = "$appPath/lib/Migration";
  80. $this->migrationsNamespace = $namespace . '\\Migration';
  81. $infoParser = new InfoParser();
  82. $info = $infoParser->parse($appPath . '/appinfo/info.xml');
  83. if (!isset($info['dependencies']['database'])) {
  84. $this->checkOracle = true;
  85. } else {
  86. $this->checkOracle = false;
  87. foreach ($info['dependencies']['database'] as $database) {
  88. if (\is_string($database) && $database === 'oci') {
  89. $this->checkOracle = true;
  90. } else if (\is_array($database) && isset($database['@value']) && $database['@value'] === 'oci') {
  91. $this->checkOracle = true;
  92. }
  93. }
  94. }
  95. }
  96. }
  97. /**
  98. * Returns the name of the app for which this migration is executed
  99. *
  100. * @return string
  101. */
  102. public function getApp() {
  103. return $this->appName;
  104. }
  105. /**
  106. * @return bool
  107. * @codeCoverageIgnore - this will implicitly tested on installation
  108. */
  109. private function createMigrationTable() {
  110. if ($this->migrationTableCreated) {
  111. return false;
  112. }
  113. $schema = new SchemaWrapper($this->connection);
  114. /**
  115. * We drop the table when it has different columns or the definition does not
  116. * match. E.g. ownCloud uses a length of 177 for app and 14 for version.
  117. */
  118. try {
  119. $table = $schema->getTable('migrations');
  120. $columns = $table->getColumns();
  121. if (count($columns) === 2) {
  122. try {
  123. $column = $table->getColumn('app');
  124. $schemaMismatch = $column->getLength() !== 255;
  125. if (!$schemaMismatch) {
  126. $column = $table->getColumn('version');
  127. $schemaMismatch = $column->getLength() !== 255;
  128. }
  129. } catch (SchemaException $e) {
  130. // One of the columns is missing
  131. $schemaMismatch = true;
  132. }
  133. if (!$schemaMismatch) {
  134. // Table exists and schema matches: return back!
  135. $this->migrationTableCreated = true;
  136. return false;
  137. }
  138. }
  139. // Drop the table, when it didn't match our expectations.
  140. $this->connection->dropTable('migrations');
  141. // Recreate the schema after the table was dropped.
  142. $schema = new SchemaWrapper($this->connection);
  143. } catch (SchemaException $e) {
  144. // Table not found, no need to panic, we will create it.
  145. }
  146. $table = $schema->createTable('migrations');
  147. $table->addColumn('app', Type::STRING, ['length' => 255]);
  148. $table->addColumn('version', Type::STRING, ['length' => 255]);
  149. $table->setPrimaryKey(['app', 'version']);
  150. $this->connection->migrateToSchema($schema->getWrappedSchema());
  151. $this->migrationTableCreated = true;
  152. return true;
  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. $qb->select('version')
  164. ->from('migrations')
  165. ->where($qb->expr()->eq('app', $qb->createNamedParameter($this->getApp())))
  166. ->orderBy('version');
  167. $result = $qb->execute();
  168. $rows = $result->fetchAll(\PDO::FETCH_COLUMN);
  169. $result->closeCursor();
  170. return $rows;
  171. }
  172. /**
  173. * Returns all versions which are available in the migration folder
  174. *
  175. * @return array
  176. */
  177. public function getAvailableVersions() {
  178. $this->ensureMigrationsAreLoaded();
  179. return array_map('strval', array_keys($this->migrations));
  180. }
  181. protected function findMigrations() {
  182. $directory = realpath($this->migrationsPath);
  183. if ($directory === false || !file_exists($directory) || !is_dir($directory)) {
  184. return [];
  185. }
  186. $iterator = new \RegexIterator(
  187. new \RecursiveIteratorIterator(
  188. new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
  189. \RecursiveIteratorIterator::LEAVES_ONLY
  190. ),
  191. '#^.+\\/Version[^\\/]{1,255}\\.php$#i',
  192. \RegexIterator::GET_MATCH);
  193. $files = array_keys(iterator_to_array($iterator));
  194. uasort($files, function ($a, $b) {
  195. preg_match('/^Version(\d+)Date(\d+)\\.php$/', basename($a), $matchA);
  196. preg_match('/^Version(\d+)Date(\d+)\\.php$/', basename($b), $matchB);
  197. if (!empty($matchA) && !empty($matchB)) {
  198. if ($matchA[1] !== $matchB[1]) {
  199. return ($matchA[1] < $matchB[1]) ? -1 : 1;
  200. }
  201. return ($matchA[2] < $matchB[2]) ? -1 : 1;
  202. }
  203. return (basename($a) < basename($b)) ? -1 : 1;
  204. });
  205. $migrations = [];
  206. foreach ($files as $file) {
  207. $className = basename($file, '.php');
  208. $version = (string) substr($className, 7);
  209. if ($version === '0') {
  210. throw new \InvalidArgumentException(
  211. "Cannot load a migrations with the name '$version' because it is a reserved number"
  212. );
  213. }
  214. $migrations[$version] = sprintf('%s\\%s', $this->migrationsNamespace, $className);
  215. }
  216. return $migrations;
  217. }
  218. /**
  219. * @param string $to
  220. * @return string[]
  221. */
  222. private function getMigrationsToExecute($to) {
  223. $knownMigrations = $this->getMigratedVersions();
  224. $availableMigrations = $this->getAvailableVersions();
  225. $toBeExecuted = [];
  226. foreach ($availableMigrations as $v) {
  227. if ($to !== 'latest' && $v > $to) {
  228. continue;
  229. }
  230. if ($this->shallBeExecuted($v, $knownMigrations)) {
  231. $toBeExecuted[] = $v;
  232. }
  233. }
  234. return $toBeExecuted;
  235. }
  236. /**
  237. * @param string $m
  238. * @param string[] $knownMigrations
  239. * @return bool
  240. */
  241. private function shallBeExecuted($m, $knownMigrations) {
  242. if (in_array($m, $knownMigrations)) {
  243. return false;
  244. }
  245. return true;
  246. }
  247. /**
  248. * @param string $version
  249. */
  250. private function markAsExecuted($version) {
  251. $this->connection->insertIfNotExist('*PREFIX*migrations', [
  252. 'app' => $this->appName,
  253. 'version' => $version
  254. ]);
  255. }
  256. /**
  257. * Returns the name of the table which holds the already applied versions
  258. *
  259. * @return string
  260. */
  261. public function getMigrationsTableName() {
  262. return $this->connection->getPrefix() . 'migrations';
  263. }
  264. /**
  265. * Returns the namespace of the version classes
  266. *
  267. * @return string
  268. */
  269. public function getMigrationsNamespace() {
  270. return $this->migrationsNamespace;
  271. }
  272. /**
  273. * Returns the directory which holds the versions
  274. *
  275. * @return string
  276. */
  277. public function getMigrationsDirectory() {
  278. return $this->migrationsPath;
  279. }
  280. /**
  281. * Return the explicit version for the aliases; current, next, prev, latest
  282. *
  283. * @param string $alias
  284. * @return mixed|null|string
  285. */
  286. public function getMigration($alias) {
  287. switch($alias) {
  288. case 'current':
  289. return $this->getCurrentVersion();
  290. case 'next':
  291. return $this->getRelativeVersion($this->getCurrentVersion(), 1);
  292. case 'prev':
  293. return $this->getRelativeVersion($this->getCurrentVersion(), -1);
  294. case 'latest':
  295. $this->ensureMigrationsAreLoaded();
  296. $migrations = $this->getAvailableVersions();
  297. return @end($migrations);
  298. }
  299. return '0';
  300. }
  301. /**
  302. * @param string $version
  303. * @param int $delta
  304. * @return null|string
  305. */
  306. private function getRelativeVersion($version, $delta) {
  307. $this->ensureMigrationsAreLoaded();
  308. $versions = $this->getAvailableVersions();
  309. array_unshift($versions, 0);
  310. $offset = array_search($version, $versions, true);
  311. if ($offset === false || !isset($versions[$offset + $delta])) {
  312. // Unknown version or delta out of bounds.
  313. return null;
  314. }
  315. return (string) $versions[$offset + $delta];
  316. }
  317. /**
  318. * @return string
  319. */
  320. private function getCurrentVersion() {
  321. $m = $this->getMigratedVersions();
  322. if (count($m) === 0) {
  323. return '0';
  324. }
  325. $migrations = array_values($m);
  326. return @end($migrations);
  327. }
  328. /**
  329. * @param string $version
  330. * @return string
  331. * @throws \InvalidArgumentException
  332. */
  333. private function getClass($version) {
  334. $this->ensureMigrationsAreLoaded();
  335. if (isset($this->migrations[$version])) {
  336. return $this->migrations[$version];
  337. }
  338. throw new \InvalidArgumentException("Version $version is unknown.");
  339. }
  340. /**
  341. * Allows to set an IOutput implementation which is used for logging progress and messages
  342. *
  343. * @param IOutput $output
  344. */
  345. public function setOutput(IOutput $output) {
  346. $this->output = $output;
  347. }
  348. /**
  349. * Applies all not yet applied versions up to $to
  350. *
  351. * @param string $to
  352. * @param bool $schemaOnly
  353. * @throws \InvalidArgumentException
  354. */
  355. public function migrate($to = 'latest', $schemaOnly = false) {
  356. // read known migrations
  357. $toBeExecuted = $this->getMigrationsToExecute($to);
  358. foreach ($toBeExecuted as $version) {
  359. $this->executeStep($version, $schemaOnly);
  360. }
  361. }
  362. /**
  363. * Get the human readable descriptions for the migration steps to run
  364. *
  365. * @param string $to
  366. * @return string[] [$name => $description]
  367. */
  368. public function describeMigrationStep($to = 'latest') {
  369. $toBeExecuted = $this->getMigrationsToExecute($to);
  370. $description = [];
  371. foreach ($toBeExecuted as $version) {
  372. $migration = $this->createInstance($version);
  373. if ($migration->name()) {
  374. $description[$migration->name()] = $migration->description();
  375. }
  376. }
  377. return $description;
  378. }
  379. /**
  380. * @param string $version
  381. * @return IMigrationStep
  382. * @throws \InvalidArgumentException
  383. */
  384. protected function createInstance($version) {
  385. $class = $this->getClass($version);
  386. try {
  387. $s = \OC::$server->query($class);
  388. if (!$s instanceof IMigrationStep) {
  389. throw new \InvalidArgumentException('Not a valid migration');
  390. }
  391. } catch (QueryException $e) {
  392. if (class_exists($class)) {
  393. $s = new $class();
  394. } else {
  395. throw new \InvalidArgumentException("Migration step '$class' is unknown");
  396. }
  397. }
  398. return $s;
  399. }
  400. /**
  401. * Executes one explicit version
  402. *
  403. * @param string $version
  404. * @param bool $schemaOnly
  405. * @throws \InvalidArgumentException
  406. */
  407. public function executeStep($version, $schemaOnly = false) {
  408. $instance = $this->createInstance($version);
  409. if (!$schemaOnly) {
  410. $instance->preSchemaChange($this->output, function() {
  411. return new SchemaWrapper($this->connection);
  412. }, ['tablePrefix' => $this->connection->getPrefix()]);
  413. }
  414. $toSchema = $instance->changeSchema($this->output, function() {
  415. return new SchemaWrapper($this->connection);
  416. }, ['tablePrefix' => $this->connection->getPrefix()]);
  417. if ($toSchema instanceof SchemaWrapper) {
  418. $targetSchema = $toSchema->getWrappedSchema();
  419. if ($this->checkOracle) {
  420. $sourceSchema = $this->connection->createSchema();
  421. $this->ensureOracleIdentifierLengthLimit($sourceSchema, $targetSchema, strlen($this->connection->getPrefix()));
  422. }
  423. $this->connection->migrateToSchema($targetSchema);
  424. $toSchema->performDropTableCalls();
  425. }
  426. if (!$schemaOnly) {
  427. $instance->postSchemaChange($this->output, function() {
  428. return new SchemaWrapper($this->connection);
  429. }, ['tablePrefix' => $this->connection->getPrefix()]);
  430. }
  431. $this->markAsExecuted($version);
  432. }
  433. public function ensureOracleIdentifierLengthLimit(Schema $sourceSchema, Schema $targetSchema, int $prefixLength) {
  434. $sequences = $targetSchema->getSequences();
  435. foreach ($targetSchema->getTables() as $table) {
  436. try {
  437. $sourceTable = $sourceSchema->getTable($table->getName());
  438. } catch (SchemaException $e) {
  439. if (\strlen($table->getName()) - $prefixLength > 27) {
  440. throw new \InvalidArgumentException('Table name "' . $table->getName() . '" is too long.');
  441. }
  442. $sourceTable = null;
  443. }
  444. foreach ($table->getColumns() as $thing) {
  445. if ((!$sourceTable instanceof Table || !$sourceTable->hasColumn($thing->getName())) && \strlen($thing->getName()) - $prefixLength > 27) {
  446. throw new \InvalidArgumentException('Column name "' . $table->getName() . '"."' . $thing->getName() . '" is too long.');
  447. }
  448. }
  449. foreach ($table->getIndexes() as $thing) {
  450. if ((!$sourceTable instanceof Table || !$sourceTable->hasIndex($thing->getName())) && \strlen($thing->getName()) - $prefixLength > 27) {
  451. throw new \InvalidArgumentException('Index name "' . $table->getName() . '"."' . $thing->getName() . '" is too long.');
  452. }
  453. }
  454. foreach ($table->getForeignKeys() as $thing) {
  455. if ((!$sourceTable instanceof Table || !$sourceTable->hasForeignKey($thing->getName())) && \strlen($thing->getName()) - $prefixLength > 27) {
  456. throw new \InvalidArgumentException('Foreign key name "' . $table->getName() . '"."' . $thing->getName() . '" is too long.');
  457. }
  458. }
  459. $primaryKey = $table->getPrimaryKey();
  460. if ($primaryKey instanceof Index && (!$sourceTable instanceof Table || !$sourceTable->hasPrimaryKey())) {
  461. $indexName = strtolower($primaryKey->getName());
  462. $isUsingDefaultName = $indexName === 'primary';
  463. if ($this->connection->getDatabasePlatform() instanceof PostgreSqlPlatform) {
  464. $defaultName = $table->getName() . '_pkey';
  465. $isUsingDefaultName = strtolower($defaultName) === $indexName;
  466. if ($isUsingDefaultName) {
  467. $sequenceName = $table->getName() . '_' . implode('_', $primaryKey->getColumns()) . '_seq';
  468. $sequences = array_filter($sequences, function(Sequence $sequence) use ($sequenceName) {
  469. return $sequence->getName() !== $sequenceName;
  470. });
  471. }
  472. } else if ($this->connection->getDatabasePlatform() instanceof OraclePlatform) {
  473. $defaultName = $table->getName() . '_seq';
  474. $isUsingDefaultName = strtolower($defaultName) === $indexName;
  475. }
  476. if (!$isUsingDefaultName && \strlen($indexName) - $prefixLength > 27) {
  477. throw new \InvalidArgumentException('Primary index name on "' . $table->getName() . '" is too long.');
  478. }
  479. if ($isUsingDefaultName && \strlen($table->getName()) - $prefixLength > 23) {
  480. throw new \InvalidArgumentException('Primary index name on "' . $table->getName() . '" is too long.');
  481. }
  482. }
  483. }
  484. foreach ($sequences as $sequence) {
  485. if (!$sourceSchema->hasSequence($sequence->getName()) && \strlen($sequence->getName()) - $prefixLength > 27) {
  486. throw new \InvalidArgumentException('Sequence name "' . $sequence->getName() . '" is too long.');
  487. }
  488. }
  489. }
  490. private function ensureMigrationsAreLoaded() {
  491. if (empty($this->migrations)) {
  492. $this->migrations = $this->findMigrations();
  493. }
  494. }
  495. }