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