您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

MigrationService.php 18KB

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