MigrationService.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  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\Schema\SchemaException;
  25. use OC\IntegrityCheck\Helpers\AppLocator;
  26. use OC\Migration\SimpleOutput;
  27. use OCP\AppFramework\App;
  28. use OCP\AppFramework\QueryException;
  29. use OCP\IDBConnection;
  30. use OCP\Migration\IMigrationStep;
  31. use OCP\Migration\IOutput;
  32. use Doctrine\DBAL\Types\Type;
  33. class MigrationService {
  34. /** @var boolean */
  35. private $migrationTableCreated;
  36. /** @var array */
  37. private $migrations;
  38. /** @var IOutput */
  39. private $output;
  40. /** @var Connection */
  41. private $connection;
  42. /** @var string */
  43. private $appName;
  44. /**
  45. * MigrationService constructor.
  46. *
  47. * @param $appName
  48. * @param IDBConnection $connection
  49. * @param AppLocator $appLocator
  50. * @param IOutput|null $output
  51. * @throws \Exception
  52. */
  53. public function __construct($appName, IDBConnection $connection, IOutput $output = null, AppLocator $appLocator = null) {
  54. $this->appName = $appName;
  55. $this->connection = $connection;
  56. $this->output = $output;
  57. if (null === $this->output) {
  58. $this->output = new SimpleOutput(\OC::$server->getLogger(), $appName);
  59. }
  60. if ($appName === 'core') {
  61. $this->migrationsPath = \OC::$SERVERROOT . '/core/Migrations';
  62. $this->migrationsNamespace = 'OC\\Core\\Migrations';
  63. } else {
  64. if (null === $appLocator) {
  65. $appLocator = new AppLocator();
  66. }
  67. $appPath = $appLocator->getAppPath($appName);
  68. $namespace = App::buildAppNamespace($appName);
  69. $this->migrationsPath = "$appPath/lib/Migration";
  70. $this->migrationsNamespace = $namespace . '\\Migration';
  71. }
  72. }
  73. /**
  74. * Returns the name of the app for which this migration is executed
  75. *
  76. * @return string
  77. */
  78. public function getApp() {
  79. return $this->appName;
  80. }
  81. /**
  82. * @return bool
  83. * @codeCoverageIgnore - this will implicitly tested on installation
  84. */
  85. private function createMigrationTable() {
  86. if ($this->migrationTableCreated) {
  87. return false;
  88. }
  89. $schema = new SchemaWrapper($this->connection);
  90. /**
  91. * We drop the table when it has different columns or the definition does not
  92. * match. E.g. ownCloud uses a length of 177 for app and 14 for version.
  93. */
  94. try {
  95. $table = $schema->getTable('migrations');
  96. $columns = $table->getColumns();
  97. if (count($columns) === 2) {
  98. try {
  99. $column = $table->getColumn('app');
  100. $schemaMismatch = $column->getLength() !== 255;
  101. if (!$schemaMismatch) {
  102. $column = $table->getColumn('version');
  103. $schemaMismatch = $column->getLength() !== 255;
  104. }
  105. } catch (SchemaException $e) {
  106. // One of the columns is missing
  107. $schemaMismatch = true;
  108. }
  109. if (!$schemaMismatch) {
  110. // Table exists and schema matches: return back!
  111. $this->migrationTableCreated = true;
  112. return false;
  113. }
  114. }
  115. // Drop the table, when it didn't match our expectations.
  116. $this->connection->dropTable('migrations');
  117. // Recreate the schema after the table was dropped.
  118. $schema = new SchemaWrapper($this->connection);
  119. } catch (SchemaException $e) {
  120. // Table not found, no need to panic, we will create it.
  121. }
  122. $table = $schema->createTable('migrations');
  123. $table->addColumn('app', Type::STRING, ['length' => 255]);
  124. $table->addColumn('version', Type::STRING, ['length' => 255]);
  125. $table->setPrimaryKey(['app', 'version']);
  126. $this->connection->migrateToSchema($schema->getWrappedSchema());
  127. $this->migrationTableCreated = true;
  128. return true;
  129. }
  130. /**
  131. * Returns all versions which have already been applied
  132. *
  133. * @return string[]
  134. * @codeCoverageIgnore - no need to test this
  135. */
  136. public function getMigratedVersions() {
  137. $this->createMigrationTable();
  138. $qb = $this->connection->getQueryBuilder();
  139. $qb->select('version')
  140. ->from('migrations')
  141. ->where($qb->expr()->eq('app', $qb->createNamedParameter($this->getApp())))
  142. ->orderBy('version');
  143. $result = $qb->execute();
  144. $rows = $result->fetchAll(\PDO::FETCH_COLUMN);
  145. $result->closeCursor();
  146. return $rows;
  147. }
  148. /**
  149. * Returns all versions which are available in the migration folder
  150. *
  151. * @return array
  152. */
  153. public function getAvailableVersions() {
  154. $this->ensureMigrationsAreLoaded();
  155. return array_map('strval', array_keys($this->migrations));
  156. }
  157. protected function findMigrations() {
  158. $directory = realpath($this->migrationsPath);
  159. if ($directory === false || !file_exists($directory) || !is_dir($directory)) {
  160. return [];
  161. }
  162. $iterator = new \RegexIterator(
  163. new \RecursiveIteratorIterator(
  164. new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
  165. \RecursiveIteratorIterator::LEAVES_ONLY
  166. ),
  167. '#^.+\\/Version[^\\/]{1,255}\\.php$#i',
  168. \RegexIterator::GET_MATCH);
  169. $files = array_keys(iterator_to_array($iterator));
  170. uasort($files, function ($a, $b) {
  171. preg_match('/^Version(\d+)Date(\d+)\\.php$/', basename($a), $matchA);
  172. preg_match('/^Version(\d+)Date(\d+)\\.php$/', basename($b), $matchB);
  173. if (!empty($matchA) && !empty($matchB)) {
  174. if ($matchA[1] !== $matchB[1]) {
  175. return ($matchA[1] < $matchB[1]) ? -1 : 1;
  176. }
  177. return ($matchA[2] < $matchB[2]) ? -1 : 1;
  178. }
  179. return (basename($a) < basename($b)) ? -1 : 1;
  180. });
  181. $migrations = [];
  182. foreach ($files as $file) {
  183. $className = basename($file, '.php');
  184. $version = (string) substr($className, 7);
  185. if ($version === '0') {
  186. throw new \InvalidArgumentException(
  187. "Cannot load a migrations with the name '$version' because it is a reserved number"
  188. );
  189. }
  190. $migrations[$version] = sprintf('%s\\%s', $this->migrationsNamespace, $className);
  191. }
  192. return $migrations;
  193. }
  194. /**
  195. * @param string $to
  196. * @return string[]
  197. */
  198. private function getMigrationsToExecute($to) {
  199. $knownMigrations = $this->getMigratedVersions();
  200. $availableMigrations = $this->getAvailableVersions();
  201. $toBeExecuted = [];
  202. foreach ($availableMigrations as $v) {
  203. if ($to !== 'latest' && $v > $to) {
  204. continue;
  205. }
  206. if ($this->shallBeExecuted($v, $knownMigrations)) {
  207. $toBeExecuted[] = $v;
  208. }
  209. }
  210. return $toBeExecuted;
  211. }
  212. /**
  213. * @param string $m
  214. * @param string[] $knownMigrations
  215. * @return bool
  216. */
  217. private function shallBeExecuted($m, $knownMigrations) {
  218. if (in_array($m, $knownMigrations)) {
  219. return false;
  220. }
  221. return true;
  222. }
  223. /**
  224. * @param string $version
  225. */
  226. private function markAsExecuted($version) {
  227. $this->connection->insertIfNotExist('*PREFIX*migrations', [
  228. 'app' => $this->appName,
  229. 'version' => $version
  230. ]);
  231. }
  232. /**
  233. * Returns the name of the table which holds the already applied versions
  234. *
  235. * @return string
  236. */
  237. public function getMigrationsTableName() {
  238. return $this->connection->getPrefix() . 'migrations';
  239. }
  240. /**
  241. * Returns the namespace of the version classes
  242. *
  243. * @return string
  244. */
  245. public function getMigrationsNamespace() {
  246. return $this->migrationsNamespace;
  247. }
  248. /**
  249. * Returns the directory which holds the versions
  250. *
  251. * @return string
  252. */
  253. public function getMigrationsDirectory() {
  254. return $this->migrationsPath;
  255. }
  256. /**
  257. * Return the explicit version for the aliases; current, next, prev, latest
  258. *
  259. * @param string $alias
  260. * @return mixed|null|string
  261. */
  262. public function getMigration($alias) {
  263. switch($alias) {
  264. case 'current':
  265. return $this->getCurrentVersion();
  266. case 'next':
  267. return $this->getRelativeVersion($this->getCurrentVersion(), 1);
  268. case 'prev':
  269. return $this->getRelativeVersion($this->getCurrentVersion(), -1);
  270. case 'latest':
  271. $this->ensureMigrationsAreLoaded();
  272. $migrations = $this->getAvailableVersions();
  273. return @end($migrations);
  274. }
  275. return '0';
  276. }
  277. /**
  278. * @param string $version
  279. * @param int $delta
  280. * @return null|string
  281. */
  282. private function getRelativeVersion($version, $delta) {
  283. $this->ensureMigrationsAreLoaded();
  284. $versions = $this->getAvailableVersions();
  285. array_unshift($versions, 0);
  286. $offset = array_search($version, $versions, true);
  287. if ($offset === false || !isset($versions[$offset + $delta])) {
  288. // Unknown version or delta out of bounds.
  289. return null;
  290. }
  291. return (string) $versions[$offset + $delta];
  292. }
  293. /**
  294. * @return string
  295. */
  296. private function getCurrentVersion() {
  297. $m = $this->getMigratedVersions();
  298. if (count($m) === 0) {
  299. return '0';
  300. }
  301. $migrations = array_values($m);
  302. return @end($migrations);
  303. }
  304. /**
  305. * @param string $version
  306. * @return string
  307. * @throws \InvalidArgumentException
  308. */
  309. private function getClass($version) {
  310. $this->ensureMigrationsAreLoaded();
  311. if (isset($this->migrations[$version])) {
  312. return $this->migrations[$version];
  313. }
  314. throw new \InvalidArgumentException("Version $version is unknown.");
  315. }
  316. /**
  317. * Allows to set an IOutput implementation which is used for logging progress and messages
  318. *
  319. * @param IOutput $output
  320. */
  321. public function setOutput(IOutput $output) {
  322. $this->output = $output;
  323. }
  324. /**
  325. * Applies all not yet applied versions up to $to
  326. *
  327. * @param string $to
  328. * @throws \InvalidArgumentException
  329. */
  330. public function migrate($to = 'latest') {
  331. // read known migrations
  332. $toBeExecuted = $this->getMigrationsToExecute($to);
  333. foreach ($toBeExecuted as $version) {
  334. $this->executeStep($version);
  335. }
  336. }
  337. /**
  338. * @param string $version
  339. * @return mixed
  340. * @throws \InvalidArgumentException
  341. */
  342. protected function createInstance($version) {
  343. $class = $this->getClass($version);
  344. try {
  345. $s = \OC::$server->query($class);
  346. } catch (QueryException $e) {
  347. if (class_exists($class)) {
  348. $s = new $class();
  349. } else {
  350. throw new \InvalidArgumentException("Migration step '$class' is unknown");
  351. }
  352. }
  353. return $s;
  354. }
  355. /**
  356. * Executes one explicit version
  357. *
  358. * @param string $version
  359. * @throws \InvalidArgumentException
  360. */
  361. public function executeStep($version) {
  362. $instance = $this->createInstance($version);
  363. if (!$instance instanceof IMigrationStep) {
  364. throw new \InvalidArgumentException('Not a valid migration');
  365. }
  366. $instance->preSchemaChange($this->output, function() {
  367. return new SchemaWrapper($this->connection);
  368. }, ['tablePrefix' => $this->connection->getPrefix()]);
  369. $toSchema = $instance->changeSchema($this->output, function() {
  370. return new SchemaWrapper($this->connection);
  371. }, ['tablePrefix' => $this->connection->getPrefix()]);
  372. if ($toSchema instanceof SchemaWrapper) {
  373. $this->connection->migrateToSchema($toSchema->getWrappedSchema());
  374. $toSchema->performDropTableCalls();
  375. }
  376. $instance->postSchemaChange($this->output, function() {
  377. return new SchemaWrapper($this->connection);
  378. }, ['tablePrefix' => $this->connection->getPrefix()]);
  379. $this->markAsExecuted($version);
  380. }
  381. private function ensureMigrationsAreLoaded() {
  382. if (empty($this->migrations)) {
  383. $this->migrations = $this->findMigrations();
  384. }
  385. }
  386. }