Passed
Push — master ( c73993...689523 )
by Blizzz
11:51
created

ensureOracleIdentifierLengthLimit()   F

Complexity

Conditions 29
Paths 386

Size

Total Lines 63
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 29
eloc 38
nc 386
nop 3
dl 0
loc 63
rs 1.0083
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 Joas Schilling <[email protected]>
7
 *
8
 * @license AGPL-3.0
9
 *
10
 * This code is free software: you can redistribute it and/or modify
11
 * it under the terms of the GNU Affero General Public License, version 3,
12
 * as published by the Free Software Foundation.
13
 *
14
 * This program is distributed in the hope that it will be useful,
15
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
 * GNU Affero General Public License for more details.
18
 *
19
 * You should have received a copy of the GNU Affero General Public License, version 3,
20
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
21
 *
22
 */
23
24
namespace OC\DB;
25
26
use Doctrine\DBAL\Platforms\OraclePlatform;
27
use Doctrine\DBAL\Platforms\PostgreSqlPlatform;
28
use Doctrine\DBAL\Schema\Index;
29
use Doctrine\DBAL\Schema\Schema;
30
use Doctrine\DBAL\Schema\SchemaException;
31
use Doctrine\DBAL\Schema\Sequence;
32
use Doctrine\DBAL\Schema\Table;
33
use OC\App\InfoParser;
34
use OC\IntegrityCheck\Helpers\AppLocator;
35
use OC\Migration\SimpleOutput;
36
use OCP\AppFramework\App;
37
use OCP\AppFramework\QueryException;
38
use OCP\IDBConnection;
39
use OCP\Migration\IMigrationStep;
40
use OCP\Migration\IOutput;
41
use Doctrine\DBAL\Types\Type;
42
43
class MigrationService {
44
45
	/** @var boolean */
46
	private $migrationTableCreated;
47
	/** @var array */
48
	private $migrations;
49
	/** @var IOutput */
50
	private $output;
51
	/** @var Connection */
52
	private $connection;
53
	/** @var string */
54
	private $appName;
55
	/** @var bool */
56
	private $checkOracle;
57
58
	/**
59
	 * MigrationService constructor.
60
	 *
61
	 * @param $appName
62
	 * @param IDBConnection $connection
63
	 * @param AppLocator $appLocator
64
	 * @param IOutput|null $output
65
	 * @throws \Exception
66
	 */
67
	public function __construct($appName, IDBConnection $connection, IOutput $output = null, AppLocator $appLocator = null) {
68
		$this->appName = $appName;
69
		$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...
70
		$this->output = $output;
71
		if (null === $this->output) {
72
			$this->output = new SimpleOutput(\OC::$server->getLogger(), $appName);
73
		}
74
75
		if ($appName === 'core') {
76
			$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...
77
			$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...
78
			$this->checkOracle = true;
79
		} else {
80
			if (null === $appLocator) {
81
				$appLocator = new AppLocator();
82
			}
83
			$appPath = $appLocator->getAppPath($appName);
84
			$namespace = App::buildAppNamespace($appName);
85
			$this->migrationsPath = "$appPath/lib/Migration";
86
			$this->migrationsNamespace = $namespace . '\\Migration';
87
88
			$infoParser = new InfoParser();
89
			$info = $infoParser->parse($appPath . '/appinfo/info.xml');
90
			if (!isset($info['dependencies']['database'])) {
91
				$this->checkOracle = true;
92
			} else {
93
				$this->checkOracle = false;
94
				foreach ($info['dependencies']['database'] as $database) {
95
					if (\is_string($database) && $database === 'oci') {
96
						$this->checkOracle = true;
97
					} else if (\is_array($database) && isset($database['@value']) && $database['@value'] === 'oci') {
98
						$this->checkOracle = true;
99
					}
100
				}
101
			}
102
		}
103
	}
104
105
	/**
106
	 * Returns the name of the app for which this migration is executed
107
	 *
108
	 * @return string
109
	 */
110
	public function getApp() {
111
		return $this->appName;
112
	}
113
114
	/**
115
	 * @return bool
116
	 * @codeCoverageIgnore - this will implicitly tested on installation
117
	 */
118
	private function createMigrationTable() {
119
		if ($this->migrationTableCreated) {
120
			return false;
121
		}
122
123
		$schema = new SchemaWrapper($this->connection);
124
125
		/**
126
		 * We drop the table when it has different columns or the definition does not
127
		 * match. E.g. ownCloud uses a length of 177 for app and 14 for version.
128
		 */
129
		try {
130
			$table = $schema->getTable('migrations');
131
			$columns = $table->getColumns();
132
133
			if (count($columns) === 2) {
134
				try {
135
					$column = $table->getColumn('app');
136
					$schemaMismatch = $column->getLength() !== 255;
137
138
					if (!$schemaMismatch) {
139
						$column = $table->getColumn('version');
140
						$schemaMismatch = $column->getLength() !== 255;
141
					}
142
				} catch (SchemaException $e) {
143
					// One of the columns is missing
144
					$schemaMismatch = true;
145
				}
146
147
				if (!$schemaMismatch) {
148
					// Table exists and schema matches: return back!
149
					$this->migrationTableCreated = true;
150
					return false;
151
				}
152
			}
153
154
			// Drop the table, when it didn't match our expectations.
155
			$this->connection->dropTable('migrations');
156
157
			// Recreate the schema after the table was dropped.
158
			$schema = new SchemaWrapper($this->connection);
159
160
		} catch (SchemaException $e) {
161
			// Table not found, no need to panic, we will create it.
162
		}
163
164
		$table = $schema->createTable('migrations');
165
		$table->addColumn('app', Type::STRING, ['length' => 255]);
166
		$table->addColumn('version', Type::STRING, ['length' => 255]);
167
		$table->setPrimaryKey(['app', 'version']);
168
169
		$this->connection->migrateToSchema($schema->getWrappedSchema());
170
171
		$this->migrationTableCreated = true;
172
173
		return true;
174
	}
175
176
	/**
177
	 * Returns all versions which have already been applied
178
	 *
179
	 * @return string[]
180
	 * @codeCoverageIgnore - no need to test this
181
	 */
182
	public function getMigratedVersions() {
183
		$this->createMigrationTable();
184
		$qb = $this->connection->getQueryBuilder();
185
186
		$qb->select('version')
187
			->from('migrations')
188
			->where($qb->expr()->eq('app', $qb->createNamedParameter($this->getApp())))
189
			->orderBy('version');
190
191
		$result = $qb->execute();
192
		$rows = $result->fetchAll(\PDO::FETCH_COLUMN);
193
		$result->closeCursor();
194
195
		return $rows;
196
	}
197
198
	/**
199
	 * Returns all versions which are available in the migration folder
200
	 *
201
	 * @return array
202
	 */
203
	public function getAvailableVersions() {
204
		$this->ensureMigrationsAreLoaded();
205
		return array_map('strval', array_keys($this->migrations));
206
	}
207
208
	protected function findMigrations() {
209
		$directory = realpath($this->migrationsPath);
210
		if ($directory === false || !file_exists($directory) || !is_dir($directory)) {
211
			return [];
212
		}
213
214
		$iterator = new \RegexIterator(
215
			new \RecursiveIteratorIterator(
216
				new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
217
				\RecursiveIteratorIterator::LEAVES_ONLY
218
			),
219
			'#^.+\\/Version[^\\/]{1,255}\\.php$#i',
220
			\RegexIterator::GET_MATCH);
221
222
		$files = array_keys(iterator_to_array($iterator));
223
		uasort($files, function ($a, $b) {
224
			preg_match('/^Version(\d+)Date(\d+)\\.php$/', basename($a), $matchA);
225
			preg_match('/^Version(\d+)Date(\d+)\\.php$/', basename($b), $matchB);
226
			if (!empty($matchA) && !empty($matchB)) {
227
				if ($matchA[1] !== $matchB[1]) {
228
					return ($matchA[1] < $matchB[1]) ? -1 : 1;
229
				}
230
				return ($matchA[2] < $matchB[2]) ? -1 : 1;
231
			}
232
			return (basename($a) < basename($b)) ? -1 : 1;
233
		});
234
235
		$migrations = [];
236
237
		foreach ($files as $file) {
238
			$className = basename($file, '.php');
239
			$version = (string) substr($className, 7);
240
			if ($version === '0') {
241
				throw new \InvalidArgumentException(
242
					"Cannot load a migrations with the name '$version' because it is a reserved number"
243
				);
244
			}
245
			$migrations[$version] = sprintf('%s\\%s', $this->migrationsNamespace, $className);
246
		}
247
248
		return $migrations;
249
	}
250
251
	/**
252
	 * @param string $to
253
	 * @return string[]
254
	 */
255
	private function getMigrationsToExecute($to) {
256
		$knownMigrations = $this->getMigratedVersions();
257
		$availableMigrations = $this->getAvailableVersions();
258
259
		$toBeExecuted = [];
260
		foreach ($availableMigrations as $v) {
261
			if ($to !== 'latest' && $v > $to) {
262
				continue;
263
			}
264
			if ($this->shallBeExecuted($v, $knownMigrations)) {
265
				$toBeExecuted[] = $v;
266
			}
267
		}
268
269
		return $toBeExecuted;
270
	}
271
272
	/**
273
	 * @param string $m
274
	 * @param string[] $knownMigrations
275
	 * @return bool
276
	 */
277
	private function shallBeExecuted($m, $knownMigrations) {
278
		if (in_array($m, $knownMigrations)) {
279
			return false;
280
		}
281
282
		return true;
283
	}
284
285
	/**
286
	 * @param string $version
287
	 */
288
	private function markAsExecuted($version) {
289
		$this->connection->insertIfNotExist('*PREFIX*migrations', [
0 ignored issues
show
Deprecated Code introduced by
The function OC\DB\Connection::insertIfNotExist() has been deprecated: 15.0.0 - use unique index and "try { $db->insert() } catch (UniqueConstraintViolationException $e) {}" instead, because it is more reliable and does not have the risk for deadlocks - see https://github.com/nextcloud/server/pull/12371 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

289
		/** @scrutinizer ignore-deprecated */ $this->connection->insertIfNotExist('*PREFIX*migrations', [

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
290
			'app' => $this->appName,
291
			'version' => $version
292
		]);
293
	}
294
295
	/**
296
	 * Returns the name of the table which holds the already applied versions
297
	 *
298
	 * @return string
299
	 */
300
	public function getMigrationsTableName() {
301
		return $this->connection->getPrefix() . 'migrations';
302
	}
303
304
	/**
305
	 * Returns the namespace of the version classes
306
	 *
307
	 * @return string
308
	 */
309
	public function getMigrationsNamespace() {
310
		return $this->migrationsNamespace;
311
	}
312
313
	/**
314
	 * Returns the directory which holds the versions
315
	 *
316
	 * @return string
317
	 */
318
	public function getMigrationsDirectory() {
319
		return $this->migrationsPath;
320
	}
321
322
	/**
323
	 * Return the explicit version for the aliases; current, next, prev, latest
324
	 *
325
	 * @param string $alias
326
	 * @return mixed|null|string
327
	 */
328
	public function getMigration($alias) {
329
		switch($alias) {
330
			case 'current':
331
				return $this->getCurrentVersion();
332
			case 'next':
333
				return $this->getRelativeVersion($this->getCurrentVersion(), 1);
334
			case 'prev':
335
				return $this->getRelativeVersion($this->getCurrentVersion(), -1);
336
			case 'latest':
337
				$this->ensureMigrationsAreLoaded();
338
339
				$migrations = $this->getAvailableVersions();
340
				return @end($migrations);
341
		}
342
		return '0';
343
	}
344
345
	/**
346
	 * @param string $version
347
	 * @param int $delta
348
	 * @return null|string
349
	 */
350
	private function getRelativeVersion($version, $delta) {
351
		$this->ensureMigrationsAreLoaded();
352
353
		$versions = $this->getAvailableVersions();
354
		array_unshift($versions, 0);
355
		$offset = array_search($version, $versions, true);
356
		if ($offset === false || !isset($versions[$offset + $delta])) {
357
			// Unknown version or delta out of bounds.
358
			return null;
359
		}
360
361
		return (string) $versions[$offset + $delta];
362
	}
363
364
	/**
365
	 * @return string
366
	 */
367
	private function getCurrentVersion() {
368
		$m = $this->getMigratedVersions();
369
		if (count($m) === 0) {
370
			return '0';
371
		}
372
		$migrations = array_values($m);
373
		return @end($migrations);
374
	}
375
376
	/**
377
	 * @param string $version
378
	 * @return string
379
	 * @throws \InvalidArgumentException
380
	 */
381
	private function getClass($version) {
382
		$this->ensureMigrationsAreLoaded();
383
384
		if (isset($this->migrations[$version])) {
385
			return $this->migrations[$version];
386
		}
387
388
		throw new \InvalidArgumentException("Version $version is unknown.");
389
	}
390
391
	/**
392
	 * Allows to set an IOutput implementation which is used for logging progress and messages
393
	 *
394
	 * @param IOutput $output
395
	 */
396
	public function setOutput(IOutput $output) {
397
		$this->output = $output;
398
	}
399
400
	/**
401
	 * Applies all not yet applied versions up to $to
402
	 *
403
	 * @param string $to
404
	 * @param bool $schemaOnly
405
	 * @throws \InvalidArgumentException
406
	 */
407
	public function migrate($to = 'latest', $schemaOnly = false) {
408
		// read known migrations
409
		$toBeExecuted = $this->getMigrationsToExecute($to);
410
		foreach ($toBeExecuted as $version) {
411
			$this->executeStep($version, $schemaOnly);
412
		}
413
	}
414
415
	/**
416
	 * Get the human readable descriptions for the migration steps to run
417
	 *
418
	 * @param string $to
419
	 * @return string[] [$name => $description]
420
	 */
421
	public function describeMigrationStep($to = 'latest') {
422
		$toBeExecuted = $this->getMigrationsToExecute($to);
423
		$description = [];
424
		foreach ($toBeExecuted as $version) {
425
			$migration = $this->createInstance($version);
426
			if ($migration->name()) {
427
				$description[$migration->name()] = $migration->description();
428
			}
429
		}
430
		return $description;
431
	}
432
433
	/**
434
	 * @param string $version
435
	 * @return IMigrationStep
436
	 * @throws \InvalidArgumentException
437
	 */
438
	protected function createInstance($version) {
439
		$class = $this->getClass($version);
440
		try {
441
			$s = \OC::$server->query($class);
442
443
			if (!$s instanceof IMigrationStep) {
444
				throw new \InvalidArgumentException('Not a valid migration');
445
			}
446
		} catch (QueryException $e) {
447
			if (class_exists($class)) {
448
				$s = new $class();
449
			} else {
450
				throw new \InvalidArgumentException("Migration step '$class' is unknown");
451
			}
452
		}
453
454
		return $s;
455
	}
456
457
	/**
458
	 * Executes one explicit version
459
	 *
460
	 * @param string $version
461
	 * @param bool $schemaOnly
462
	 * @throws \InvalidArgumentException
463
	 */
464
	public function executeStep($version, $schemaOnly = false) {
465
		$instance = $this->createInstance($version);
466
467
		if (!$schemaOnly) {
468
			$instance->preSchemaChange($this->output, function() {
469
				return new SchemaWrapper($this->connection);
470
			}, ['tablePrefix' => $this->connection->getPrefix()]);
471
		}
472
473
		$toSchema = $instance->changeSchema($this->output, function() {
474
			return new SchemaWrapper($this->connection);
475
		}, ['tablePrefix' => $this->connection->getPrefix()]);
476
477
		if ($toSchema instanceof SchemaWrapper) {
478
			$targetSchema = $toSchema->getWrappedSchema();
479
			if ($this->checkOracle) {
480
				$sourceSchema = $this->connection->createSchema();
481
				$this->ensureOracleIdentifierLengthLimit($sourceSchema, $targetSchema, strlen($this->connection->getPrefix()));
482
			}
483
			$this->connection->migrateToSchema($targetSchema);
484
			$toSchema->performDropTableCalls();
485
		}
486
487
		if (!$schemaOnly) {
488
			$instance->postSchemaChange($this->output, function() {
489
				return new SchemaWrapper($this->connection);
490
			}, ['tablePrefix' => $this->connection->getPrefix()]);
491
		}
492
493
		$this->markAsExecuted($version);
494
	}
495
496
	public function ensureOracleIdentifierLengthLimit(Schema $sourceSchema, Schema $targetSchema, int $prefixLength) {
497
		$sequences = $targetSchema->getSequences();
498
499
		foreach ($targetSchema->getTables() as $table) {
500
			try {
501
				$sourceTable = $sourceSchema->getTable($table->getName());
502
			} catch (SchemaException $e) {
503
				if (\strlen($table->getName()) - $prefixLength > 27) {
504
					throw new \InvalidArgumentException('Table name "'  . $table->getName() . '" is too long.');
505
				}
506
				$sourceTable = null;
507
			}
508
509
			foreach ($table->getColumns() as $thing) {
510
				if ((!$sourceTable instanceof Table || !$sourceTable->hasColumn($thing->getName())) && \strlen($thing->getName()) - $prefixLength > 27) {
511
					throw new \InvalidArgumentException('Column name "'  . $table->getName() . '"."' . $thing->getName() . '" is too long.');
512
				}
513
			}
514
515
			foreach ($table->getIndexes() as $thing) {
516
				if ((!$sourceTable instanceof Table || !$sourceTable->hasIndex($thing->getName())) && \strlen($thing->getName()) - $prefixLength > 27) {
517
					throw new \InvalidArgumentException('Index name "'  . $table->getName() . '"."' . $thing->getName() . '" is too long.');
518
				}
519
			}
520
521
			foreach ($table->getForeignKeys() as $thing) {
522
				if ((!$sourceTable instanceof Table || !$sourceTable->hasForeignKey($thing->getName())) && \strlen($thing->getName()) - $prefixLength > 27) {
523
					throw new \InvalidArgumentException('Foreign key name "'  . $table->getName() . '"."' . $thing->getName() . '" is too long.');
524
				}
525
			}
526
527
			$primaryKey = $table->getPrimaryKey();
528
			if ($primaryKey instanceof Index && (!$sourceTable instanceof Table || !$sourceTable->hasPrimaryKey())) {
529
				$indexName = strtolower($primaryKey->getName());
530
				$isUsingDefaultName = $indexName === 'primary';
531
532
				if ($this->connection->getDatabasePlatform() instanceof PostgreSqlPlatform) {
533
					$defaultName = $table->getName() . '_pkey';
534
					$isUsingDefaultName = strtolower($defaultName) === $indexName;
535
536
					if ($isUsingDefaultName) {
537
						$sequenceName = $table->getName() . '_' . implode('_', $primaryKey->getColumns()) . '_seq';
538
						$sequences = array_filter($sequences, function(Sequence $sequence) use ($sequenceName) {
539
							return $sequence->getName() !== $sequenceName;
540
						});
541
					}
542
				} else if ($this->connection->getDatabasePlatform() instanceof OraclePlatform) {
543
					$defaultName = $table->getName() . '_seq';
544
					$isUsingDefaultName = strtolower($defaultName) === $indexName;
545
				}
546
547
				if (!$isUsingDefaultName && \strlen($indexName) - $prefixLength > 27) {
548
					throw new \InvalidArgumentException('Primary index name  on "'  . $table->getName() . '" is too long.');
549
				}
550
				if ($isUsingDefaultName && \strlen($table->getName()) - $prefixLength > 23) {
551
					throw new \InvalidArgumentException('Primary index name  on "'  . $table->getName() . '" is too long.');
552
				}
553
			}
554
		}
555
556
		foreach ($sequences as $sequence) {
557
			if (!$sourceSchema->hasSequence($sequence->getName()) && \strlen($sequence->getName()) - $prefixLength > 27) {
558
				throw new \InvalidArgumentException('Sequence name "'  . $sequence->getName() . '" is too long.');
559
			}
560
		}
561
	}
562
563
	private function ensureMigrationsAreLoaded() {
564
		if (empty($this->migrations)) {
565
			$this->migrations = $this->findMigrations();
566
		}
567
	}
568
}
569