You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

576 lines
16 KiB

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