Passed
Push — master ( 0c7bed...48f1f9 )
by Joas
14:07 queued 12s
created

MigrationService::migrateSchemaOnly()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 27
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 17
c 1
b 0
f 0
nc 7
nop 1
dl 0
loc 27
rs 8.8333
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
		if ($this->connection->tableExists('migrations')) {
128
			$this->migrationTableCreated = true;
129
			return false;
130
		}
131
132
		$schema = new SchemaWrapper($this->connection);
133
134
		/**
135
		 * We drop the table when it has different columns or the definition does not
136
		 * match. E.g. ownCloud uses a length of 177 for app and 14 for version.
137
		 */
138
		try {
139
			$table = $schema->getTable('migrations');
140
			$columns = $table->getColumns();
141
142
			if (count($columns) === 2) {
143
				try {
144
					$column = $table->getColumn('app');
145
					$schemaMismatch = $column->getLength() !== 255;
146
147
					if (!$schemaMismatch) {
148
						$column = $table->getColumn('version');
149
						$schemaMismatch = $column->getLength() !== 255;
150
					}
151
				} catch (SchemaException $e) {
152
					// One of the columns is missing
153
					$schemaMismatch = true;
154
				}
155
156
				if (!$schemaMismatch) {
157
					// Table exists and schema matches: return back!
158
					$this->migrationTableCreated = true;
159
					return false;
160
				}
161
			}
162
163
			// Drop the table, when it didn't match our expectations.
164
			$this->connection->dropTable('migrations');
165
166
			// Recreate the schema after the table was dropped.
167
			$schema = new SchemaWrapper($this->connection);
168
		} catch (SchemaException $e) {
169
			// Table not found, no need to panic, we will create it.
170
		}
171
172
		$table = $schema->createTable('migrations');
173
		$table->addColumn('app', Types::STRING, ['length' => 255]);
174
		$table->addColumn('version', Types::STRING, ['length' => 255]);
175
		$table->setPrimaryKey(['app', 'version']);
176
177
		$this->connection->migrateToSchema($schema->getWrappedSchema());
178
179
		$this->migrationTableCreated = true;
180
181
		return true;
182
	}
183
184
	/**
185
	 * Returns all versions which have already been applied
186
	 *
187
	 * @return string[]
188
	 * @codeCoverageIgnore - no need to test this
189
	 */
190
	public function getMigratedVersions() {
191
		$this->createMigrationTable();
192
		$qb = $this->connection->getQueryBuilder();
193
194
		$qb->select('version')
195
			->from('migrations')
196
			->where($qb->expr()->eq('app', $qb->createNamedParameter($this->getApp())))
197
			->orderBy('version');
198
199
		$result = $qb->execute();
200
		$rows = $result->fetchAll(\PDO::FETCH_COLUMN);
201
		$result->closeCursor();
202
203
		return $rows;
204
	}
205
206
	/**
207
	 * Returns all versions which are available in the migration folder
208
	 *
209
	 * @return array
210
	 */
211
	public function getAvailableVersions() {
212
		$this->ensureMigrationsAreLoaded();
213
		return array_map('strval', array_keys($this->migrations));
214
	}
215
216
	protected function findMigrations() {
217
		$directory = realpath($this->migrationsPath);
218
		if ($directory === false || !file_exists($directory) || !is_dir($directory)) {
219
			return [];
220
		}
221
222
		$iterator = new \RegexIterator(
223
			new \RecursiveIteratorIterator(
224
				new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
225
				\RecursiveIteratorIterator::LEAVES_ONLY
226
			),
227
			'#^.+\\/Version[^\\/]{1,255}\\.php$#i',
228
			\RegexIterator::GET_MATCH);
229
230
		$files = array_keys(iterator_to_array($iterator));
231
		uasort($files, function ($a, $b) {
232
			preg_match('/^Version(\d+)Date(\d+)\\.php$/', basename($a), $matchA);
233
			preg_match('/^Version(\d+)Date(\d+)\\.php$/', basename($b), $matchB);
234
			if (!empty($matchA) && !empty($matchB)) {
235
				if ($matchA[1] !== $matchB[1]) {
236
					return ($matchA[1] < $matchB[1]) ? -1 : 1;
237
				}
238
				return ($matchA[2] < $matchB[2]) ? -1 : 1;
239
			}
240
			return (basename($a) < basename($b)) ? -1 : 1;
241
		});
242
243
		$migrations = [];
244
245
		foreach ($files as $file) {
246
			$className = basename($file, '.php');
247
			$version = (string) substr($className, 7);
248
			if ($version === '0') {
249
				throw new \InvalidArgumentException(
250
					"Cannot load a migrations with the name '$version' because it is a reserved number"
251
				);
252
			}
253
			$migrations[$version] = sprintf('%s\\%s', $this->migrationsNamespace, $className);
254
		}
255
256
		return $migrations;
257
	}
258
259
	/**
260
	 * @param string $to
261
	 * @return string[]
262
	 */
263
	private function getMigrationsToExecute($to) {
264
		$knownMigrations = $this->getMigratedVersions();
265
		$availableMigrations = $this->getAvailableVersions();
266
267
		$toBeExecuted = [];
268
		foreach ($availableMigrations as $v) {
269
			if ($to !== 'latest' && $v > $to) {
270
				continue;
271
			}
272
			if ($this->shallBeExecuted($v, $knownMigrations)) {
273
				$toBeExecuted[] = $v;
274
			}
275
		}
276
277
		return $toBeExecuted;
278
	}
279
280
	/**
281
	 * @param string $m
282
	 * @param string[] $knownMigrations
283
	 * @return bool
284
	 */
285
	private function shallBeExecuted($m, $knownMigrations) {
286
		if (in_array($m, $knownMigrations)) {
287
			return false;
288
		}
289
290
		return true;
291
	}
292
293
	/**
294
	 * @param string $version
295
	 */
296
	private function markAsExecuted($version) {
297
		$this->connection->insertIfNotExist('*PREFIX*migrations', [
298
			'app' => $this->appName,
299
			'version' => $version
300
		]);
301
	}
302
303
	/**
304
	 * Returns the name of the table which holds the already applied versions
305
	 *
306
	 * @return string
307
	 */
308
	public function getMigrationsTableName() {
309
		return $this->connection->getPrefix() . 'migrations';
310
	}
311
312
	/**
313
	 * Returns the namespace of the version classes
314
	 *
315
	 * @return string
316
	 */
317
	public function getMigrationsNamespace() {
318
		return $this->migrationsNamespace;
319
	}
320
321
	/**
322
	 * Returns the directory which holds the versions
323
	 *
324
	 * @return string
325
	 */
326
	public function getMigrationsDirectory() {
327
		return $this->migrationsPath;
328
	}
329
330
	/**
331
	 * Return the explicit version for the aliases; current, next, prev, latest
332
	 *
333
	 * @param string $alias
334
	 * @return mixed|null|string
335
	 */
336
	public function getMigration($alias) {
337
		switch ($alias) {
338
			case 'current':
339
				return $this->getCurrentVersion();
340
			case 'next':
341
				return $this->getRelativeVersion($this->getCurrentVersion(), 1);
342
			case 'prev':
343
				return $this->getRelativeVersion($this->getCurrentVersion(), -1);
344
			case 'latest':
345
				$this->ensureMigrationsAreLoaded();
346
347
				$migrations = $this->getAvailableVersions();
348
				return @end($migrations);
349
		}
350
		return '0';
351
	}
352
353
	/**
354
	 * @param string $version
355
	 * @param int $delta
356
	 * @return null|string
357
	 */
358
	private function getRelativeVersion($version, $delta) {
359
		$this->ensureMigrationsAreLoaded();
360
361
		$versions = $this->getAvailableVersions();
362
		array_unshift($versions, 0);
363
		$offset = array_search($version, $versions, true);
364
		if ($offset === false || !isset($versions[$offset + $delta])) {
365
			// Unknown version or delta out of bounds.
366
			return null;
367
		}
368
369
		return (string) $versions[$offset + $delta];
370
	}
371
372
	/**
373
	 * @return string
374
	 */
375
	private function getCurrentVersion() {
376
		$m = $this->getMigratedVersions();
377
		if (count($m) === 0) {
378
			return '0';
379
		}
380
		$migrations = array_values($m);
381
		return @end($migrations);
382
	}
383
384
	/**
385
	 * @param string $version
386
	 * @return string
387
	 * @throws \InvalidArgumentException
388
	 */
389
	private function getClass($version) {
390
		$this->ensureMigrationsAreLoaded();
391
392
		if (isset($this->migrations[$version])) {
393
			return $this->migrations[$version];
394
		}
395
396
		throw new \InvalidArgumentException("Version $version is unknown.");
397
	}
398
399
	/**
400
	 * Allows to set an IOutput implementation which is used for logging progress and messages
401
	 *
402
	 * @param IOutput $output
403
	 */
404
	public function setOutput(IOutput $output) {
405
		$this->output = $output;
406
	}
407
408
	/**
409
	 * Applies all not yet applied versions up to $to
410
	 *
411
	 * @param string $to
412
	 * @param bool $schemaOnly
413
	 * @throws \InvalidArgumentException
414
	 */
415
	public function migrate($to = 'latest', $schemaOnly = false) {
416
		if ($schemaOnly) {
417
			$this->migrateSchemaOnly($to);
418
			return;
419
		}
420
421
		// read known migrations
422
		$toBeExecuted = $this->getMigrationsToExecute($to);
423
		foreach ($toBeExecuted as $version) {
424
			$this->executeStep($version, $schemaOnly);
425
		}
426
	}
427
428
	/**
429
	 * Applies all not yet applied versions up to $to
430
	 *
431
	 * @param string $to
432
	 * @throws \InvalidArgumentException
433
	 */
434
	public function migrateSchemaOnly($to = 'latest') {
435
		// read known migrations
436
		$toBeExecuted = $this->getMigrationsToExecute($to);
437
438
		if (empty($toBeExecuted)) {
439
			return;
440
		}
441
442
		$toSchema = null;
443
		foreach ($toBeExecuted as $version) {
444
			$instance = $this->createInstance($version);
445
446
			$toSchema = $instance->changeSchema($this->output, function () use ($toSchema) {
447
				return $toSchema ?: new SchemaWrapper($this->connection);
0 ignored issues
show
introduced by
$toSchema is of type null, thus it always evaluated to false.
Loading history...
448
			}, ['tablePrefix' => $this->connection->getPrefix()]) ?: $toSchema;
449
450
			$this->markAsExecuted($version);
451
		}
452
453
		if ($toSchema instanceof SchemaWrapper) {
454
			$targetSchema = $toSchema->getWrappedSchema();
455
			if ($this->checkOracle) {
456
				$beforeSchema = $this->connection->createSchema();
457
				$this->ensureOracleIdentifierLengthLimit($beforeSchema, $targetSchema, strlen($this->connection->getPrefix()));
458
			}
459
			$this->connection->migrateToSchema($targetSchema);
460
			$toSchema->performDropTableCalls();
461
		}
462
	}
463
464
	/**
465
	 * Get the human readable descriptions for the migration steps to run
466
	 *
467
	 * @param string $to
468
	 * @return string[] [$name => $description]
469
	 */
470
	public function describeMigrationStep($to = 'latest') {
471
		$toBeExecuted = $this->getMigrationsToExecute($to);
472
		$description = [];
473
		foreach ($toBeExecuted as $version) {
474
			$migration = $this->createInstance($version);
475
			if ($migration->name()) {
476
				$description[$migration->name()] = $migration->description();
477
			}
478
		}
479
		return $description;
480
	}
481
482
	/**
483
	 * @param string $version
484
	 * @return IMigrationStep
485
	 * @throws \InvalidArgumentException
486
	 */
487
	protected function createInstance($version) {
488
		$class = $this->getClass($version);
489
		try {
490
			$s = \OC::$server->query($class);
491
492
			if (!$s instanceof IMigrationStep) {
493
				throw new \InvalidArgumentException('Not a valid migration');
494
			}
495
		} catch (QueryException $e) {
496
			if (class_exists($class)) {
497
				$s = new $class();
498
			} else {
499
				throw new \InvalidArgumentException("Migration step '$class' is unknown");
500
			}
501
		}
502
503
		return $s;
504
	}
505
506
	/**
507
	 * Executes one explicit version
508
	 *
509
	 * @param string $version
510
	 * @param bool $schemaOnly
511
	 * @throws \InvalidArgumentException
512
	 */
513
	public function executeStep($version, $schemaOnly = false) {
514
		$instance = $this->createInstance($version);
515
516
		if (!$schemaOnly) {
517
			$instance->preSchemaChange($this->output, function () {
518
				return new SchemaWrapper($this->connection);
519
			}, ['tablePrefix' => $this->connection->getPrefix()]);
520
		}
521
522
		$toSchema = $instance->changeSchema($this->output, function () {
523
			return new SchemaWrapper($this->connection);
524
		}, ['tablePrefix' => $this->connection->getPrefix()]);
525
526
		if ($toSchema instanceof SchemaWrapper) {
527
			$targetSchema = $toSchema->getWrappedSchema();
528
			if ($this->checkOracle) {
529
				$sourceSchema = $this->connection->createSchema();
530
				$this->ensureOracleIdentifierLengthLimit($sourceSchema, $targetSchema, strlen($this->connection->getPrefix()));
531
			}
532
			$this->connection->migrateToSchema($targetSchema);
533
			$toSchema->performDropTableCalls();
534
		}
535
536
		if (!$schemaOnly) {
537
			$instance->postSchemaChange($this->output, function () {
538
				return new SchemaWrapper($this->connection);
539
			}, ['tablePrefix' => $this->connection->getPrefix()]);
540
		}
541
542
		$this->markAsExecuted($version);
543
	}
544
545
	public function ensureOracleIdentifierLengthLimit(Schema $sourceSchema, Schema $targetSchema, int $prefixLength) {
546
		$sequences = $targetSchema->getSequences();
547
548
		foreach ($targetSchema->getTables() as $table) {
549
			try {
550
				$sourceTable = $sourceSchema->getTable($table->getName());
551
			} catch (SchemaException $e) {
552
				if (\strlen($table->getName()) - $prefixLength > 27) {
553
					throw new \InvalidArgumentException('Table name "'  . $table->getName() . '" is too long.');
554
				}
555
				$sourceTable = null;
556
			}
557
558
			foreach ($table->getColumns() as $thing) {
559
				if ((!$sourceTable instanceof Table || !$sourceTable->hasColumn($thing->getName())) && \strlen($thing->getName()) > 30) {
560
					throw new \InvalidArgumentException('Column name "'  . $table->getName() . '"."' . $thing->getName() . '" is too long.');
561
				}
562
563
				if ($thing->getNotnull() && $thing->getDefault() === ''
564
					&& $sourceTable instanceof Table && !$sourceTable->hasColumn($thing->getName())) {
565
					throw new \InvalidArgumentException('Column name "'  . $table->getName() . '"."' . $thing->getName() . '" is NotNull, but has empty string or null as default.');
566
				}
567
			}
568
569
			foreach ($table->getIndexes() as $thing) {
570
				if ((!$sourceTable instanceof Table || !$sourceTable->hasIndex($thing->getName())) && \strlen($thing->getName()) > 30) {
571
					throw new \InvalidArgumentException('Index name "'  . $table->getName() . '"."' . $thing->getName() . '" is too long.');
572
				}
573
			}
574
575
			foreach ($table->getForeignKeys() as $thing) {
576
				if ((!$sourceTable instanceof Table || !$sourceTable->hasForeignKey($thing->getName())) && \strlen($thing->getName()) > 30) {
577
					throw new \InvalidArgumentException('Foreign key name "'  . $table->getName() . '"."' . $thing->getName() . '" is too long.');
578
				}
579
			}
580
581
			$primaryKey = $table->getPrimaryKey();
582
			if ($primaryKey instanceof Index && (!$sourceTable instanceof Table || !$sourceTable->hasPrimaryKey())) {
583
				$indexName = strtolower($primaryKey->getName());
584
				$isUsingDefaultName = $indexName === 'primary';
585
586
				if ($this->connection->getDatabasePlatform() instanceof PostgreSqlPlatform) {
587
					$defaultName = $table->getName() . '_pkey';
588
					$isUsingDefaultName = strtolower($defaultName) === $indexName;
589
590
					if ($isUsingDefaultName) {
591
						$sequenceName = $table->getName() . '_' . implode('_', $primaryKey->getColumns()) . '_seq';
592
						$sequences = array_filter($sequences, function (Sequence $sequence) use ($sequenceName) {
593
							return $sequence->getName() !== $sequenceName;
594
						});
595
					}
596
				} elseif ($this->connection->getDatabasePlatform() instanceof OraclePlatform) {
597
					$defaultName = $table->getName() . '_seq';
598
					$isUsingDefaultName = strtolower($defaultName) === $indexName;
599
				}
600
601
				if (!$isUsingDefaultName && \strlen($indexName) > 30) {
602
					throw new \InvalidArgumentException('Primary index name  on "'  . $table->getName() . '" is too long.');
603
				}
604
				if ($isUsingDefaultName && \strlen($table->getName()) - $prefixLength >= 23) {
605
					throw new \InvalidArgumentException('Primary index name  on "'  . $table->getName() . '" is too long.');
606
				}
607
			}
608
		}
609
610
		foreach ($sequences as $sequence) {
611
			if (!$sourceSchema->hasSequence($sequence->getName()) && \strlen($sequence->getName()) > 30) {
612
				throw new \InvalidArgumentException('Sequence name "'  . $sequence->getName() . '" is too long.');
613
			}
614
		}
615
	}
616
617
	private function ensureMigrationsAreLoaded() {
618
		if (empty($this->migrations)) {
619
			$this->migrations = $this->findMigrations();
620
		}
621
	}
622
}
623