Completed
Push — master ( ca5656...60398b )
by Morris
32:43 queued 16:28
created

MigrationService::ensureMigrationsAreLoaded()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 0
dl 0
loc 5
rs 9.4285
c 0
b 0
f 0
1
<?php
2
/**
3
 * @author Joas Schilling <[email protected]>
4
 * @author Thomas Müller <[email protected]>
5
 * @author Vincent Petry <[email protected]>
6
 *
7
 * @copyright Copyright (c) 2017 Joas Schilling <[email protected]>
8
 * @copyright Copyright (c) 2017, ownCloud GmbH
9
 *
10
 * @license AGPL-3.0
11
 *
12
 * This code is free software: you can redistribute it and/or modify
13
 * it under the terms of the GNU Affero General Public License, version 3,
14
 * as published by the Free Software Foundation.
15
 *
16
 * This program is distributed in the hope that it will be useful,
17
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
 * GNU Affero General Public License for more details.
20
 *
21
 * You should have received a copy of the GNU Affero General Public License, version 3,
22
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
23
 *
24
 */
25
26
namespace OC\DB;
27
28
use Doctrine\DBAL\Schema\Schema;
29
use OC\IntegrityCheck\Helpers\AppLocator;
30
use OC\Migration\SimpleOutput;
31
use OCP\AppFramework\App;
32
use OCP\AppFramework\QueryException;
33
use OCP\IDBConnection;
34
use OCP\Migration\IMigrationStep;
35
use OCP\Migration\IOutput;
36
use Doctrine\DBAL\Schema\Column;
37
use Doctrine\DBAL\Schema\Table;
38
use Doctrine\DBAL\Types\Type;
39
40
class MigrationService {
41
42
	/** @var boolean */
43
	private $migrationTableCreated;
44
	/** @var array */
45
	private $migrations;
46
	/** @var IOutput */
47
	private $output;
48
	/** @var Connection */
49
	private $connection;
50
	/** @var string */
51
	private $appName;
52
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;
0 ignored issues
show
Documentation Bug introduced by
$connection is of type object<OCP\IDBConnection>, but the property $connection was declared to be of type object<OC\DB\Connection>. Are you sure that you always receive this specific sub-class here, or does it make sense to add an instanceof check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a given class or a super-class is assigned to a property that is type hinted more strictly.

Either this assignment is in error or an instanceof check should be added for that assignment.

class Alien {}

class Dalek extends Alien {}

class Plot
{
    /** @var  Dalek */
    public $villain;
}

$alien = new Alien();
$plot = new Plot();
if ($alien instanceof Dalek) {
    $plot->villain = $alien;
}
Loading history...
65
		$this->output = $output;
66
		if (null === $this->output) {
67
			$this->output = new SimpleOutput(\OC::$server->getLogger(), $appName);
68
		}
69
70
		if ($appName === 'core') {
71
			$this->migrationsPath = \OC::$SERVERROOT . '/core/Migrations';
0 ignored issues
show
Bug introduced by
The property migrationsPath does not seem to exist. Did you mean migrations?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
72
			$this->migrationsNamespace = 'OC\\Core\\Migrations';
0 ignored issues
show
Bug introduced by
The property migrationsNamespace does not seem to exist. Did you mean migrations?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
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";
0 ignored issues
show
Bug introduced by
The property migrationsPath does not seem to exist. Did you mean migrations?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
80
			$this->migrationsNamespace = $namespace . '\\Migration';
0 ignored issues
show
Bug introduced by
The property migrationsNamespace does not seem to exist. Did you mean migrations?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
81
82
			if (!@mkdir($appPath . '/lib') && !is_dir($appPath . '/lib')) {
83
				throw new \RuntimeException("Could not create migration folder \"{$this->migrationsPath}\"");
0 ignored issues
show
Bug introduced by
The property migrationsPath does not seem to exist. Did you mean migrations?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
84
			}
85
		}
86
87
		if (!@mkdir($this->migrationsPath) && !is_dir($this->migrationsPath)) {
0 ignored issues
show
Bug introduced by
The property migrationsPath does not seem to exist. Did you mean migrations?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
88
			throw new \RuntimeException("Could not create migration folder \"{$this->migrationsPath}\"");
0 ignored issues
show
Bug introduced by
The property migrationsPath does not seem to exist. Did you mean migrations?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
89
		}
90
	}
91
92
	/**
93
	 * Returns the name of the app for which this migration is executed
94
	 *
95
	 * @return string
96
	 */
97
	public function getApp() {
98
		return $this->appName;
99
	}
100
101
	/**
102
	 * @return bool
103
	 * @codeCoverageIgnore - this will implicitly tested on installation
104
	 */
105
	private function createMigrationTable() {
106
		if ($this->migrationTableCreated) {
107
			return false;
108
		}
109
110
		if ($this->connection->tableExists('migrations')) {
111
			$this->migrationTableCreated = true;
112
			return false;
113
		}
114
115
		$tableName = $this->connection->getPrefix() . 'migrations';
116
		$tableName = $this->connection->getDatabasePlatform()->quoteIdentifier($tableName);
117
118
		$columns = [
119
			'app' => new Column($this->connection->getDatabasePlatform()->quoteIdentifier('app'), Type::getType('string'), ['length' => 255]),
120
			'version' => new Column($this->connection->getDatabasePlatform()->quoteIdentifier('version'), Type::getType('string'), ['length' => 255]),
121
		];
122
		$table = new Table($tableName, $columns);
123
		$table->setPrimaryKey([
124
			$this->connection->getDatabasePlatform()->quoteIdentifier('app'),
125
			$this->connection->getDatabasePlatform()->quoteIdentifier('version')]);
126
		$this->connection->getSchemaManager()->createTable($table);
127
128
		$this->migrationTableCreated = true;
129
130
		return true;
131
	}
132
133
	/**
134
	 * Returns all versions which have already been applied
135
	 *
136
	 * @return string[]
137
	 * @codeCoverageIgnore - no need to test this
138
	 */
139 View Code Duplication
	public function getMigratedVersions() {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
140
		$this->createMigrationTable();
141
		$qb = $this->connection->getQueryBuilder();
142
143
		$qb->select('version')
144
			->from('migrations')
145
			->where($qb->expr()->eq('app', $qb->createNamedParameter($this->getApp())))
146
			->orderBy('version');
147
148
		$result = $qb->execute();
149
		$rows = $result->fetchAll(\PDO::FETCH_COLUMN);
150
		$result->closeCursor();
151
152
		return $rows;
153
	}
154
155
	/**
156
	 * Returns all versions which are available in the migration folder
157
	 *
158
	 * @return array
159
	 */
160
	public function getAvailableVersions() {
161
		$this->ensureMigrationsAreLoaded();
162
		return array_map('strval', array_keys($this->migrations));
163
	}
164
165
	protected function findMigrations() {
166
		$directory = realpath($this->migrationsPath);
0 ignored issues
show
Bug introduced by
The property migrationsPath does not seem to exist. Did you mean migrations?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
167
		$iterator = new \RegexIterator(
168
			new \RecursiveIteratorIterator(
169
				new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
170
				\RecursiveIteratorIterator::LEAVES_ONLY
171
			),
172
			'#^.+\\/Version[^\\/]{1,255}\\.php$#i',
173
			\RegexIterator::GET_MATCH);
174
175
		$files = array_keys(iterator_to_array($iterator));
176
		uasort($files, function ($a, $b) {
177
			preg_match('/^Version(\d+)Date(\d+)\\.php$/', basename($a), $matchA);
178
			preg_match('/^Version(\d+)Date(\d+)\\.php$/', basename($b), $matchB);
179
			if (!empty($matchA) && !empty($matchB)) {
180
				if ($matchA[1] !== $matchB[1]) {
181
					return ($matchA[1] < $matchB[1]) ? -1 : 1;
182
				}
183
				return ($matchA[2] < $matchB[2]) ? -1 : 1;
184
			}
185
			return (basename($a) < basename($b)) ? -1 : 1;
186
		});
187
188
		$migrations = [];
189
190
		foreach ($files as $file) {
191
			$className = basename($file, '.php');
192
			$version = (string) substr($className, 7);
193
			if ($version === '0') {
194
				throw new \InvalidArgumentException(
195
					"Cannot load a migrations with the name '$version' because it is a reserved number"
196
				);
197
			}
198
			$migrations[$version] = sprintf('%s\\%s', $this->migrationsNamespace, $className);
0 ignored issues
show
Bug introduced by
The property migrationsNamespace does not seem to exist. Did you mean migrations?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
199
		}
200
201
		return $migrations;
202
	}
203
204
	/**
205
	 * @param string $to
206
	 * @return string[]
207
	 */
208
	private function getMigrationsToExecute($to) {
209
		$knownMigrations = $this->getMigratedVersions();
210
		$availableMigrations = $this->getAvailableVersions();
211
212
		$toBeExecuted = [];
213
		foreach ($availableMigrations as $v) {
214
			if ($to !== 'latest' && $v > $to) {
215
				continue;
216
			}
217
			if ($this->shallBeExecuted($v, $knownMigrations)) {
218
				$toBeExecuted[] = $v;
219
			}
220
		}
221
222
		return $toBeExecuted;
223
	}
224
225
	/**
226
	 * @param string $m
227
	 * @param string[] $knownMigrations
228
	 * @return bool
229
	 */
230
	private function shallBeExecuted($m, $knownMigrations) {
231
		if (in_array($m, $knownMigrations)) {
232
			return false;
233
		}
234
235
		return true;
236
	}
237
238
	/**
239
	 * @param string $version
240
	 */
241
	private function markAsExecuted($version) {
242
		$this->connection->insertIfNotExist('*PREFIX*migrations', [
243
			'app' => $this->appName,
244
			'version' => $version
245
		]);
246
	}
247
248
	/**
249
	 * Returns the name of the table which holds the already applied versions
250
	 *
251
	 * @return string
252
	 */
253
	public function getMigrationsTableName() {
254
		return $this->connection->getPrefix() . 'migrations';
255
	}
256
257
	/**
258
	 * Returns the namespace of the version classes
259
	 *
260
	 * @return string
261
	 */
262
	public function getMigrationsNamespace() {
263
		return $this->migrationsNamespace;
0 ignored issues
show
Bug introduced by
The property migrationsNamespace does not seem to exist. Did you mean migrations?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
264
	}
265
266
	/**
267
	 * Returns the directory which holds the versions
268
	 *
269
	 * @return string
270
	 */
271
	public function getMigrationsDirectory() {
272
		return $this->migrationsPath;
0 ignored issues
show
Bug introduced by
The property migrationsPath does not seem to exist. Did you mean migrations?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
273
	}
274
275
	/**
276
	 * Return the explicit version for the aliases; current, next, prev, latest
277
	 *
278
	 * @param string $alias
279
	 * @return mixed|null|string
280
	 */
281
	public function getMigration($alias) {
282
		switch($alias) {
283
			case 'current':
284
				return $this->getCurrentVersion();
285
			case 'next':
286
				return $this->getRelativeVersion($this->getCurrentVersion(), 1);
287
			case 'prev':
288
				return $this->getRelativeVersion($this->getCurrentVersion(), -1);
289
			case 'latest':
290
				$this->ensureMigrationsAreLoaded();
291
292
				return @end($this->getAvailableVersions());
0 ignored issues
show
Bug introduced by
$this->getAvailableVersions() cannot be passed to end() as the parameter $array expects a reference.
Loading history...
293
		}
294
		return '0';
295
	}
296
297
	/**
298
	 * @param string $version
299
	 * @param int $delta
300
	 * @return null|string
301
	 */
302
	private function getRelativeVersion($version, $delta) {
303
		$this->ensureMigrationsAreLoaded();
304
305
		$versions = $this->getAvailableVersions();
306
		array_unshift($versions, 0);
307
		$offset = array_search($version, $versions, true);
308
		if ($offset === false || !isset($versions[$offset + $delta])) {
309
			// Unknown version or delta out of bounds.
310
			return null;
311
		}
312
313
		return (string) $versions[$offset + $delta];
314
	}
315
316
	/**
317
	 * @return string
318
	 */
319
	private function getCurrentVersion() {
320
		$m = $this->getMigratedVersions();
321
		if (count($m) === 0) {
322
			return '0';
323
		}
324
		return @end(array_values($m));
0 ignored issues
show
Bug introduced by
array_values($m) cannot be passed to end() as the parameter $array expects a reference.
Loading history...
325
	}
326
327
	/**
328
	 * @param string $version
329
	 * @return string
330
	 * @throws \InvalidArgumentException
331
	 */
332
	private function getClass($version) {
333
		$this->ensureMigrationsAreLoaded();
334
335
		if (isset($this->migrations[$version])) {
336
			return $this->migrations[$version];
337
		}
338
339
		throw new \InvalidArgumentException("Version $version is unknown.");
340
	}
341
342
	/**
343
	 * Allows to set an IOutput implementation which is used for logging progress and messages
344
	 *
345
	 * @param IOutput $output
346
	 */
347
	public function setOutput(IOutput $output) {
348
		$this->output = $output;
349
	}
350
351
	/**
352
	 * Applies all not yet applied versions up to $to
353
	 *
354
	 * @param string $to
355
	 * @throws \InvalidArgumentException
356
	 */
357
	public function migrate($to = 'latest') {
358
		// read known migrations
359
		$toBeExecuted = $this->getMigrationsToExecute($to);
360
		foreach ($toBeExecuted as $version) {
361
			$this->executeStep($version);
362
		}
363
	}
364
365
	/**
366
	 * @param string $version
367
	 * @return mixed
368
	 * @throws \InvalidArgumentException
369
	 */
370 View Code Duplication
	protected function createInstance($version) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
371
		$class = $this->getClass($version);
372
		try {
373
			$s = \OC::$server->query($class);
374
		} catch (QueryException $e) {
375
			if (class_exists($class)) {
376
				$s = new $class();
377
			} else {
378
				throw new \InvalidArgumentException("Migration step '$class' is unknown");
379
			}
380
		}
381
382
		return $s;
383
	}
384
385
	/**
386
	 * Executes one explicit version
387
	 *
388
	 * @param string $version
389
	 * @throws \InvalidArgumentException
390
	 */
391
	public function executeStep($version) {
392
		$instance = $this->createInstance($version);
393
		if (!$instance instanceof IMigrationStep) {
394
			throw new \InvalidArgumentException('Not a valid migration');
395
		}
396
397
		$instance->preSchemaChange($this->output, function() {
398
			return $this->connection->createSchema();
399
		}, ['tablePrefix' => $this->connection->getPrefix()]);
400
401
		$toSchema = $instance->changeSchema($this->output, function() {
402
			return new SchemaWrapper($this->connection);
403
		}, ['tablePrefix' => $this->connection->getPrefix()]);
404
405
		if ($toSchema instanceof SchemaWrapper) {
406
			$this->connection->migrateToSchema($toSchema->getWrappedSchema());
407
			$toSchema->performDropTableCalls();
408
		}
409
410
		$instance->postSchemaChange($this->output, function() {
411
			return $this->connection->createSchema();
412
		}, ['tablePrefix' => $this->connection->getPrefix()]);
413
414
		$this->markAsExecuted($version);
415
	}
416
417
	private function ensureMigrationsAreLoaded() {
418
		if (empty($this->migrations)) {
419
			$this->migrations = $this->findMigrations();
420
		}
421
	}
422
}
423