Passed
Push — master ( 507fac...5fb909 )
by Morris
14:04 queued 11s
created

MigrationService::ensureOracleConstraints()   F

Complexity

Conditions 35
Paths 390

Size

Total Lines 72
Code Lines 43

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 35
eloc 43
nc 390
nop 3
dl 0
loc 72
rs 0.9583
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 Julius Härtl <[email protected]>
10
 * @author Morris Jobke <[email protected]>
11
 * @author Robin Appelman <[email protected]>
12
 *
13
 * @license AGPL-3.0
14
 *
15
 * This code is free software: you can redistribute it and/or modify
16
 * it under the terms of the GNU Affero General Public License, version 3,
17
 * as published by the Free Software Foundation.
18
 *
19
 * This program is distributed in the hope that it will be useful,
20
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
21
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22
 * GNU Affero General Public License for more details.
23
 *
24
 * You should have received a copy of the GNU Affero General Public License, version 3,
25
 * along with this program. If not, see <http://www.gnu.org/licenses/>
26
 *
27
 */
28
29
namespace OC\DB;
30
31
use Doctrine\DBAL\Exception\DriverException;
32
use Doctrine\DBAL\Platforms\OraclePlatform;
33
use Doctrine\DBAL\Platforms\PostgreSQL94Platform;
34
use Doctrine\DBAL\Schema\Index;
35
use Doctrine\DBAL\Schema\Schema;
36
use Doctrine\DBAL\Schema\SchemaException;
37
use Doctrine\DBAL\Schema\Sequence;
38
use Doctrine\DBAL\Schema\Table;
39
use Doctrine\DBAL\Types\Types;
40
use OC\App\InfoParser;
41
use OC\IntegrityCheck\Helpers\AppLocator;
42
use OC\Migration\SimpleOutput;
43
use OCP\AppFramework\App;
44
use OCP\AppFramework\QueryException;
45
use OCP\Migration\IMigrationStep;
46
use OCP\Migration\IOutput;
47
48
class MigrationService {
49
50
	/** @var boolean */
51
	private $migrationTableCreated;
52
	/** @var array */
53
	private $migrations;
54
	/** @var IOutput */
55
	private $output;
56
	/** @var Connection */
57
	private $connection;
58
	/** @var string */
59
	private $appName;
60
	/** @var bool */
61
	private $checkOracle;
62
63
	/**
64
	 * MigrationService constructor.
65
	 *
66
	 * @param $appName
67
	 * @param Connection $connection
68
	 * @param AppLocator $appLocator
69
	 * @param IOutput|null $output
70
	 * @throws \Exception
71
	 */
72
	public function __construct($appName, Connection $connection, IOutput $output = null, AppLocator $appLocator = null) {
73
		$this->appName = $appName;
74
		$this->connection = $connection;
75
		$this->output = $output;
76
		if (null === $this->output) {
77
			$this->output = new SimpleOutput(\OC::$server->getLogger(), $appName);
78
		}
79
80
		if ($appName === 'core') {
81
			$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...
82
			$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...
83
			$this->checkOracle = true;
84
		} else {
85
			if (null === $appLocator) {
86
				$appLocator = new AppLocator();
87
			}
88
			$appPath = $appLocator->getAppPath($appName);
89
			$namespace = App::buildAppNamespace($appName);
90
			$this->migrationsPath = "$appPath/lib/Migration";
91
			$this->migrationsNamespace = $namespace . '\\Migration';
92
93
			$infoParser = new InfoParser();
94
			$info = $infoParser->parse($appPath . '/appinfo/info.xml');
95
			if (!isset($info['dependencies']['database'])) {
96
				$this->checkOracle = true;
97
			} else {
98
				$this->checkOracle = false;
99
				foreach ($info['dependencies']['database'] as $database) {
100
					if (\is_string($database) && $database === 'oci') {
101
						$this->checkOracle = true;
102
					} elseif (\is_array($database) && isset($database['@value']) && $database['@value'] === 'oci') {
103
						$this->checkOracle = true;
104
					}
105
				}
106
			}
107
		}
108
	}
109
110
	/**
111
	 * Returns the name of the app for which this migration is executed
112
	 *
113
	 * @return string
114
	 */
115
	public function getApp() {
116
		return $this->appName;
117
	}
118
119
	/**
120
	 * @return bool
121
	 * @codeCoverageIgnore - this will implicitly tested on installation
122
	 */
123
	private function createMigrationTable() {
124
		if ($this->migrationTableCreated) {
125
			return false;
126
		}
127
128
		if ($this->connection->tableExists('migrations') && \OC::$server->getConfig()->getAppValue('core', 'vendor', '') !== 'owncloud') {
129
			$this->migrationTableCreated = true;
130
			return false;
131
		}
132
133
		$schema = new SchemaWrapper($this->connection);
134
135
		/**
136
		 * We drop the table when it has different columns or the definition does not
137
		 * match. E.g. ownCloud uses a length of 177 for app and 14 for version.
138
		 */
139
		try {
140
			$table = $schema->getTable('migrations');
141
			$columns = $table->getColumns();
142
143
			if (count($columns) === 2) {
144
				try {
145
					$column = $table->getColumn('app');
146
					$schemaMismatch = $column->getLength() !== 255;
147
148
					if (!$schemaMismatch) {
149
						$column = $table->getColumn('version');
150
						$schemaMismatch = $column->getLength() !== 255;
151
					}
152
				} catch (SchemaException $e) {
153
					// One of the columns is missing
154
					$schemaMismatch = true;
155
				}
156
157
				if (!$schemaMismatch) {
158
					// Table exists and schema matches: return back!
159
					$this->migrationTableCreated = true;
160
					return false;
161
				}
162
			}
163
164
			// Drop the table, when it didn't match our expectations.
165
			$this->connection->dropTable('migrations');
166
167
			// Recreate the schema after the table was dropped.
168
			$schema = new SchemaWrapper($this->connection);
169
		} catch (SchemaException $e) {
170
			// Table not found, no need to panic, we will create it.
171
		}
172
173
		$table = $schema->createTable('migrations');
174
		$table->addColumn('app', Types::STRING, ['length' => 255]);
175
		$table->addColumn('version', Types::STRING, ['length' => 255]);
176
		$table->setPrimaryKey(['app', 'version']);
177
178
		$this->connection->migrateToSchema($schema->getWrappedSchema());
179
180
		$this->migrationTableCreated = true;
181
182
		return true;
183
	}
184
185
	/**
186
	 * Returns all versions which have already been applied
187
	 *
188
	 * @return string[]
189
	 * @codeCoverageIgnore - no need to test this
190
	 */
191
	public function getMigratedVersions() {
192
		$this->createMigrationTable();
193
		$qb = $this->connection->getQueryBuilder();
194
195
		$qb->select('version')
196
			->from('migrations')
197
			->where($qb->expr()->eq('app', $qb->createNamedParameter($this->getApp())))
198
			->orderBy('version');
199
200
		$result = $qb->execute();
201
		$rows = $result->fetchAll(\PDO::FETCH_COLUMN);
202
		$result->closeCursor();
203
204
		return $rows;
205
	}
206
207
	/**
208
	 * Returns all versions which are available in the migration folder
209
	 *
210
	 * @return array
211
	 */
212
	public function getAvailableVersions() {
213
		$this->ensureMigrationsAreLoaded();
214
		return array_map('strval', array_keys($this->migrations));
215
	}
216
217
	protected function findMigrations() {
218
		$directory = realpath($this->migrationsPath);
219
		if ($directory === false || !file_exists($directory) || !is_dir($directory)) {
220
			return [];
221
		}
222
223
		$iterator = new \RegexIterator(
224
			new \RecursiveIteratorIterator(
225
				new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
226
				\RecursiveIteratorIterator::LEAVES_ONLY
227
			),
228
			'#^.+\\/Version[^\\/]{1,255}\\.php$#i',
229
			\RegexIterator::GET_MATCH);
230
231
		$files = array_keys(iterator_to_array($iterator));
232
		uasort($files, function ($a, $b) {
233
			preg_match('/^Version(\d+)Date(\d+)\\.php$/', basename($a), $matchA);
234
			preg_match('/^Version(\d+)Date(\d+)\\.php$/', basename($b), $matchB);
235
			if (!empty($matchA) && !empty($matchB)) {
236
				if ($matchA[1] !== $matchB[1]) {
237
					return ($matchA[1] < $matchB[1]) ? -1 : 1;
238
				}
239
				return ($matchA[2] < $matchB[2]) ? -1 : 1;
240
			}
241
			return (basename($a) < basename($b)) ? -1 : 1;
242
		});
243
244
		$migrations = [];
245
246
		foreach ($files as $file) {
247
			$className = basename($file, '.php');
248
			$version = (string) substr($className, 7);
249
			if ($version === '0') {
250
				throw new \InvalidArgumentException(
251
					"Cannot load a migrations with the name '$version' because it is a reserved number"
252
				);
253
			}
254
			$migrations[$version] = sprintf('%s\\%s', $this->migrationsNamespace, $className);
255
		}
256
257
		return $migrations;
258
	}
259
260
	/**
261
	 * @param string $to
262
	 * @return string[]
263
	 */
264
	private function getMigrationsToExecute($to) {
265
		$knownMigrations = $this->getMigratedVersions();
266
		$availableMigrations = $this->getAvailableVersions();
267
268
		$toBeExecuted = [];
269
		foreach ($availableMigrations as $v) {
270
			if ($to !== 'latest' && $v > $to) {
271
				continue;
272
			}
273
			if ($this->shallBeExecuted($v, $knownMigrations)) {
274
				$toBeExecuted[] = $v;
275
			}
276
		}
277
278
		return $toBeExecuted;
279
	}
280
281
	/**
282
	 * @param string $m
283
	 * @param string[] $knownMigrations
284
	 * @return bool
285
	 */
286
	private function shallBeExecuted($m, $knownMigrations) {
287
		if (in_array($m, $knownMigrations)) {
288
			return false;
289
		}
290
291
		return true;
292
	}
293
294
	/**
295
	 * @param string $version
296
	 */
297
	private function markAsExecuted($version) {
298
		$this->connection->insertIfNotExist('*PREFIX*migrations', [
299
			'app' => $this->appName,
300
			'version' => $version
301
		]);
302
	}
303
304
	/**
305
	 * Returns the name of the table which holds the already applied versions
306
	 *
307
	 * @return string
308
	 */
309
	public function getMigrationsTableName() {
310
		return $this->connection->getPrefix() . 'migrations';
311
	}
312
313
	/**
314
	 * Returns the namespace of the version classes
315
	 *
316
	 * @return string
317
	 */
318
	public function getMigrationsNamespace() {
319
		return $this->migrationsNamespace;
320
	}
321
322
	/**
323
	 * Returns the directory which holds the versions
324
	 *
325
	 * @return string
326
	 */
327
	public function getMigrationsDirectory() {
328
		return $this->migrationsPath;
329
	}
330
331
	/**
332
	 * Return the explicit version for the aliases; current, next, prev, latest
333
	 *
334
	 * @param string $alias
335
	 * @return mixed|null|string
336
	 */
337
	public function getMigration($alias) {
338
		switch ($alias) {
339
			case 'current':
340
				return $this->getCurrentVersion();
341
			case 'next':
342
				return $this->getRelativeVersion($this->getCurrentVersion(), 1);
343
			case 'prev':
344
				return $this->getRelativeVersion($this->getCurrentVersion(), -1);
345
			case 'latest':
346
				$this->ensureMigrationsAreLoaded();
347
348
				$migrations = $this->getAvailableVersions();
349
				return @end($migrations);
350
		}
351
		return '0';
352
	}
353
354
	/**
355
	 * @param string $version
356
	 * @param int $delta
357
	 * @return null|string
358
	 */
359
	private function getRelativeVersion($version, $delta) {
360
		$this->ensureMigrationsAreLoaded();
361
362
		$versions = $this->getAvailableVersions();
363
		array_unshift($versions, 0);
364
		$offset = array_search($version, $versions, true);
365
		if ($offset === false || !isset($versions[$offset + $delta])) {
366
			// Unknown version or delta out of bounds.
367
			return null;
368
		}
369
370
		return (string) $versions[$offset + $delta];
371
	}
372
373
	/**
374
	 * @return string
375
	 */
376
	private function getCurrentVersion() {
377
		$m = $this->getMigratedVersions();
378
		if (count($m) === 0) {
379
			return '0';
380
		}
381
		$migrations = array_values($m);
382
		return @end($migrations);
383
	}
384
385
	/**
386
	 * @param string $version
387
	 * @return string
388
	 * @throws \InvalidArgumentException
389
	 */
390
	private function getClass($version) {
391
		$this->ensureMigrationsAreLoaded();
392
393
		if (isset($this->migrations[$version])) {
394
			return $this->migrations[$version];
395
		}
396
397
		throw new \InvalidArgumentException("Version $version is unknown.");
398
	}
399
400
	/**
401
	 * Allows to set an IOutput implementation which is used for logging progress and messages
402
	 *
403
	 * @param IOutput $output
404
	 */
405
	public function setOutput(IOutput $output) {
406
		$this->output = $output;
407
	}
408
409
	/**
410
	 * Applies all not yet applied versions up to $to
411
	 *
412
	 * @param string $to
413
	 * @param bool $schemaOnly
414
	 * @throws \InvalidArgumentException
415
	 */
416
	public function migrate($to = 'latest', $schemaOnly = false) {
417
		if ($schemaOnly) {
418
			$this->migrateSchemaOnly($to);
419
			return;
420
		}
421
422
		// read known migrations
423
		$toBeExecuted = $this->getMigrationsToExecute($to);
424
		foreach ($toBeExecuted as $version) {
425
			try {
426
				$this->executeStep($version, $schemaOnly);
427
			} catch (DriverException $e) {
428
				// The exception itself does not contain the name of the migration,
429
				// so we wrap it here, to make debugging easier.
430
				throw new \Exception('Database error when running migration ' . $to . ' for app ' . $this->getApp(), 0, $e);
431
			}
432
		}
433
	}
434
435
	/**
436
	 * Applies all not yet applied versions up to $to
437
	 *
438
	 * @param string $to
439
	 * @throws \InvalidArgumentException
440
	 */
441
	public function migrateSchemaOnly($to = 'latest') {
442
		// read known migrations
443
		$toBeExecuted = $this->getMigrationsToExecute($to);
444
445
		if (empty($toBeExecuted)) {
446
			return;
447
		}
448
449
		$toSchema = null;
450
		foreach ($toBeExecuted as $version) {
451
			$instance = $this->createInstance($version);
452
453
			$toSchema = $instance->changeSchema($this->output, function () use ($toSchema) {
454
				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...
455
			}, ['tablePrefix' => $this->connection->getPrefix()]) ?: $toSchema;
456
		}
457
458
		if ($toSchema instanceof SchemaWrapper) {
459
			$targetSchema = $toSchema->getWrappedSchema();
460
			if ($this->checkOracle) {
461
				$beforeSchema = $this->connection->createSchema();
462
				$this->ensureOracleConstraints($beforeSchema, $targetSchema, strlen($this->connection->getPrefix()));
463
			}
464
			$this->connection->migrateToSchema($targetSchema);
465
			$toSchema->performDropTableCalls();
466
		}
467
468
		foreach ($toBeExecuted as $version) {
469
			$this->markAsExecuted($version);
470
		}
471
	}
472
473
	/**
474
	 * Get the human readable descriptions for the migration steps to run
475
	 *
476
	 * @param string $to
477
	 * @return string[] [$name => $description]
478
	 */
479
	public function describeMigrationStep($to = 'latest') {
480
		$toBeExecuted = $this->getMigrationsToExecute($to);
481
		$description = [];
482
		foreach ($toBeExecuted as $version) {
483
			$migration = $this->createInstance($version);
484
			if ($migration->name()) {
485
				$description[$migration->name()] = $migration->description();
486
			}
487
		}
488
		return $description;
489
	}
490
491
	/**
492
	 * @param string $version
493
	 * @return IMigrationStep
494
	 * @throws \InvalidArgumentException
495
	 */
496
	protected function createInstance($version) {
497
		$class = $this->getClass($version);
498
		try {
499
			$s = \OC::$server->query($class);
500
501
			if (!$s instanceof IMigrationStep) {
502
				throw new \InvalidArgumentException('Not a valid migration');
503
			}
504
		} catch (QueryException $e) {
505
			if (class_exists($class)) {
506
				$s = new $class();
507
			} else {
508
				throw new \InvalidArgumentException("Migration step '$class' is unknown");
509
			}
510
		}
511
512
		return $s;
513
	}
514
515
	/**
516
	 * Executes one explicit version
517
	 *
518
	 * @param string $version
519
	 * @param bool $schemaOnly
520
	 * @throws \InvalidArgumentException
521
	 */
522
	public function executeStep($version, $schemaOnly = false) {
523
		$instance = $this->createInstance($version);
524
525
		if (!$schemaOnly) {
526
			$instance->preSchemaChange($this->output, function () {
527
				return new SchemaWrapper($this->connection);
528
			}, ['tablePrefix' => $this->connection->getPrefix()]);
529
		}
530
531
		$toSchema = $instance->changeSchema($this->output, function () {
532
			return new SchemaWrapper($this->connection);
533
		}, ['tablePrefix' => $this->connection->getPrefix()]);
534
535
		if ($toSchema instanceof SchemaWrapper) {
536
			$targetSchema = $toSchema->getWrappedSchema();
537
			if ($this->checkOracle) {
538
				$sourceSchema = $this->connection->createSchema();
539
				$this->ensureOracleConstraints($sourceSchema, $targetSchema, strlen($this->connection->getPrefix()));
540
			}
541
			$this->connection->migrateToSchema($targetSchema);
542
			$toSchema->performDropTableCalls();
543
		}
544
545
		if (!$schemaOnly) {
546
			$instance->postSchemaChange($this->output, function () {
547
				return new SchemaWrapper($this->connection);
548
			}, ['tablePrefix' => $this->connection->getPrefix()]);
549
		}
550
551
		$this->markAsExecuted($version);
552
	}
553
554
	/**
555
	 * Naming constraints:
556
	 * - Tables names must be 30 chars or shorter (27 + oc_ prefix)
557
	 * - Column names must be 30 chars or shorter
558
	 * - Index names must be 30 chars or shorter
559
	 * - Sequence names must be 30 chars or shorter
560
	 * - Primary key names must be set or the table name 23 chars or shorter
561
	 *
562
	 * Data constraints:
563
	 * - Columns with "NotNull" can not have empty string as default value
564
	 * - Columns with "NotNull" can not have number 0 as default value
565
	 * - Columns with type "bool" (which is in fact integer of length 1) can not be "NotNull" as it can not store 0/false
566
	 *
567
	 * @param Schema $sourceSchema
568
	 * @param Schema $targetSchema
569
	 * @param int $prefixLength
570
	 * @throws \Doctrine\DBAL\Exception
571
	 */
572
	public function ensureOracleConstraints(Schema $sourceSchema, Schema $targetSchema, int $prefixLength) {
573
		$sequences = $targetSchema->getSequences();
574
575
		foreach ($targetSchema->getTables() as $table) {
576
			try {
577
				$sourceTable = $sourceSchema->getTable($table->getName());
578
			} catch (SchemaException $e) {
579
				if (\strlen($table->getName()) - $prefixLength > 27) {
580
					throw new \InvalidArgumentException('Table name "' . $table->getName() . '" is too long.');
581
				}
582
				$sourceTable = null;
583
			}
584
585
			foreach ($table->getColumns() as $thing) {
586
				if ((!$sourceTable instanceof Table || !$sourceTable->hasColumn($thing->getName())) && \strlen($thing->getName()) > 30) {
587
					throw new \InvalidArgumentException('Column name "' . $table->getName() . '"."' . $thing->getName() . '" is too long.');
588
				}
589
590
				if ($thing->getNotnull() && $thing->getDefault() === ''
591
					&& $sourceTable instanceof Table && !$sourceTable->hasColumn($thing->getName())) {
592
					throw new \InvalidArgumentException('Column "' . $table->getName() . '"."' . $thing->getName() . '" is NotNull, but has empty string or null as default.');
593
				}
594
595
				if ($thing->getNotnull() && $thing->getType()->getName() === Types::BOOLEAN) {
596
					throw new \InvalidArgumentException('Column "' . $table->getName() . '"."' . $thing->getName() . '" is type Bool and also NotNull, so it can not store "false".');
597
				}
598
			}
599
600
			foreach ($table->getIndexes() as $thing) {
601
				if ((!$sourceTable instanceof Table || !$sourceTable->hasIndex($thing->getName())) && \strlen($thing->getName()) > 30) {
602
					throw new \InvalidArgumentException('Index name "' . $table->getName() . '"."' . $thing->getName() . '" is too long.');
603
				}
604
			}
605
606
			foreach ($table->getForeignKeys() as $thing) {
607
				if ((!$sourceTable instanceof Table || !$sourceTable->hasForeignKey($thing->getName())) && \strlen($thing->getName()) > 30) {
608
					throw new \InvalidArgumentException('Foreign key name "' . $table->getName() . '"."' . $thing->getName() . '" is too long.');
609
				}
610
			}
611
612
			$primaryKey = $table->getPrimaryKey();
613
			if ($primaryKey instanceof Index && (!$sourceTable instanceof Table || !$sourceTable->hasPrimaryKey())) {
614
				$indexName = strtolower($primaryKey->getName());
615
				$isUsingDefaultName = $indexName === 'primary';
616
617
				if ($this->connection->getDatabasePlatform() instanceof PostgreSQL94Platform) {
618
					$defaultName = $table->getName() . '_pkey';
619
					$isUsingDefaultName = strtolower($defaultName) === $indexName;
620
621
					if ($isUsingDefaultName) {
622
						$sequenceName = $table->getName() . '_' . implode('_', $primaryKey->getColumns()) . '_seq';
623
						$sequences = array_filter($sequences, function (Sequence $sequence) use ($sequenceName) {
624
							return $sequence->getName() !== $sequenceName;
625
						});
626
					}
627
				} elseif ($this->connection->getDatabasePlatform() instanceof OraclePlatform) {
628
					$defaultName = $table->getName() . '_seq';
629
					$isUsingDefaultName = strtolower($defaultName) === $indexName;
630
				}
631
632
				if (!$isUsingDefaultName && \strlen($indexName) > 30) {
633
					throw new \InvalidArgumentException('Primary index name on "' . $table->getName() . '" is too long.');
634
				}
635
				if ($isUsingDefaultName && \strlen($table->getName()) - $prefixLength >= 23) {
636
					throw new \InvalidArgumentException('Primary index name on "' . $table->getName() . '" is too long.');
637
				}
638
			}
639
		}
640
641
		foreach ($sequences as $sequence) {
642
			if (!$sourceSchema->hasSequence($sequence->getName()) && \strlen($sequence->getName()) > 30) {
643
				throw new \InvalidArgumentException('Sequence name "' . $sequence->getName() . '" is too long.');
644
			}
645
		}
646
	}
647
648
	private function ensureMigrationsAreLoaded() {
649
		if (empty($this->migrations)) {
650
			$this->migrations = $this->findMigrations();
651
		}
652
	}
653
}
654