Passed
Push — master ( 9db321...fec679 )
by Morris
10:50 queued 11s
created

MigrationService::getCurrentVersion()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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