Passed
Push — master ( aeb32e...81302f )
by Christoph
15:20 queued 10s
created

Migrator::convertStatementToScript()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 1
dl 0
loc 5
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * @copyright Copyright (c) 2016, ownCloud, Inc.
4
 *
5
 * @author Christoph Wurst <[email protected]>
6
 * @author Joas Schilling <[email protected]>
7
 * @author martin-rueegg <[email protected]>
8
 * @author Morris Jobke <[email protected]>
9
 * @author Robin Appelman <[email protected]>
10
 * @author Roeland Jago Douma <[email protected]>
11
 * @author tbelau666 <[email protected]>
12
 * @author Thomas Müller <[email protected]>
13
 * @author Victor Dubiniuk <[email protected]>
14
 * @author Vincent Petry <[email protected]>
15
 *
16
 * @license AGPL-3.0
17
 *
18
 * This code is free software: you can redistribute it and/or modify
19
 * it under the terms of the GNU Affero General Public License, version 3,
20
 * as published by the Free Software Foundation.
21
 *
22
 * This program is distributed in the hope that it will be useful,
23
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
24
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25
 * GNU Affero General Public License for more details.
26
 *
27
 * You should have received a copy of the GNU Affero General Public License, version 3,
28
 * along with this program. If not, see <http://www.gnu.org/licenses/>
29
 *
30
 */
31
32
namespace OC\DB;
33
34
use Doctrine\DBAL\Exception;
35
use Doctrine\DBAL\Schema\AbstractAsset;
36
use Doctrine\DBAL\Schema\Comparator;
37
use Doctrine\DBAL\Schema\Index;
38
use Doctrine\DBAL\Schema\Schema;
39
use Doctrine\DBAL\Schema\SchemaConfig;
40
use Doctrine\DBAL\Schema\Table;
41
use Doctrine\DBAL\Types\StringType;
42
use Doctrine\DBAL\Types\Type;
43
use OCP\IConfig;
44
use OCP\Security\ISecureRandom;
45
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
46
use Symfony\Component\EventDispatcher\GenericEvent;
47
use function preg_match;
48
49
class Migrator {
50
51
	/** @var \Doctrine\DBAL\Connection */
52
	protected $connection;
53
54
	/** @var ISecureRandom */
55
	private $random;
56
57
	/** @var IConfig */
58
	protected $config;
59
60
	/** @var EventDispatcherInterface  */
61
	private $dispatcher;
62
63
	/** @var bool */
64
	private $noEmit = false;
65
66
	/**
67
	 * @param \Doctrine\DBAL\Connection $connection
68
	 * @param ISecureRandom $random
69
	 * @param IConfig $config
70
	 * @param EventDispatcherInterface $dispatcher
71
	 */
72
	public function __construct(\Doctrine\DBAL\Connection $connection,
73
								ISecureRandom $random,
74
								IConfig $config,
75
								EventDispatcherInterface $dispatcher = null) {
76
		$this->connection = $connection;
77
		$this->random = $random;
78
		$this->config = $config;
79
		$this->dispatcher = $dispatcher;
80
	}
81
82
	/**
83
	 * @param \Doctrine\DBAL\Schema\Schema $targetSchema
84
	 */
85
	public function migrate(Schema $targetSchema) {
86
		$this->noEmit = true;
87
		$this->applySchema($targetSchema);
88
	}
89
90
	/**
91
	 * @param \Doctrine\DBAL\Schema\Schema $targetSchema
92
	 * @return string
93
	 */
94
	public function generateChangeScript(Schema $targetSchema) {
95
		$schemaDiff = $this->getDiff($targetSchema, $this->connection);
96
97
		$script = '';
98
		$sqls = $schemaDiff->toSql($this->connection->getDatabasePlatform());
99
		foreach ($sqls as $sql) {
100
			$script .= $this->convertStatementToScript($sql);
101
		}
102
103
		return $script;
104
	}
105
106
	/**
107
	 * Create a unique name for the temporary table
108
	 *
109
	 * @param string $name
110
	 * @return string
111
	 */
112
	protected function generateTemporaryTableName($name) {
113
		return $this->config->getSystemValue('dbtableprefix', 'oc_') . $name . '_' . $this->random->generate(13, ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS);
114
	}
115
116
	/**
117
	 * Check the migration of a table on a copy so we can detect errors before messing with the real table
118
	 *
119
	 * @param \Doctrine\DBAL\Schema\Table $table
120
	 * @throws \OC\DB\MigrationException
121
	 */
122
	protected function checkTableMigrate(Table $table) {
123
		$name = $table->getName();
124
		$tmpName = $this->generateTemporaryTableName($name);
125
126
		$this->copyTable($name, $tmpName);
127
128
		//create the migration schema for the temporary table
129
		$tmpTable = $this->renameTableSchema($table, $tmpName);
130
		$schemaConfig = new SchemaConfig();
131
		$schemaConfig->setName($this->connection->getDatabase());
132
		$schema = new Schema([$tmpTable], [], $schemaConfig);
133
134
		try {
135
			$this->applySchema($schema);
136
			$this->dropTable($tmpName);
137
		} catch (Exception $e) {
138
			// pgsql needs to commit it's failed transaction before doing anything else
139
			if ($this->connection->isTransactionActive()) {
140
				$this->connection->commit();
141
			}
142
			$this->dropTable($tmpName);
143
			throw new MigrationException($table->getName(), $e->getMessage());
144
		}
145
	}
146
147
	/**
148
	 * @param \Doctrine\DBAL\Schema\Table $table
149
	 * @param string $newName
150
	 * @return \Doctrine\DBAL\Schema\Table
151
	 */
152
	protected function renameTableSchema(Table $table, $newName) {
153
		/**
154
		 * @var \Doctrine\DBAL\Schema\Index[] $indexes
155
		 */
156
		$indexes = $table->getIndexes();
157
		$newIndexes = [];
158
		foreach ($indexes as $index) {
159
			if ($index->isPrimary()) {
160
				// do not rename primary key
161
				$indexName = $index->getName();
162
			} else {
163
				// avoid conflicts in index names
164
				$indexName = $this->config->getSystemValue('dbtableprefix', 'oc_') . $this->random->generate(13, ISecureRandom::CHAR_LOWER);
165
			}
166
			$newIndexes[] = new Index($indexName, $index->getColumns(), $index->isUnique(), $index->isPrimary());
167
		}
168
169
		// foreign keys are not supported so we just set it to an empty array
170
		return new Table($newName, $table->getColumns(), $newIndexes, [], [], $table->getOptions());
171
	}
172
173
	public function createSchema() {
174
		$this->connection->getConfiguration()->setSchemaAssetsFilter(function ($asset) {
175
			/** @var string|AbstractAsset $asset */
176
			$filterExpression = $this->getFilterExpression();
177
			if ($asset instanceof AbstractAsset) {
178
				return preg_match($filterExpression, $asset->getName()) !== false;
179
			}
180
			return preg_match($filterExpression, $asset) !== false;
181
		});
182
		return $this->connection->getSchemaManager()->createSchema();
183
	}
184
185
	/**
186
	 * @param Schema $targetSchema
187
	 * @param \Doctrine\DBAL\Connection $connection
188
	 * @return \Doctrine\DBAL\Schema\SchemaDiff
189
	 */
190
	protected function getDiff(Schema $targetSchema, \Doctrine\DBAL\Connection $connection) {
191
		// adjust varchar columns with a length higher then getVarcharMaxLength to clob
192
		foreach ($targetSchema->getTables() as $table) {
193
			foreach ($table->getColumns() as $column) {
194
				if ($column->getType() instanceof StringType) {
195
					if ($column->getLength() > $connection->getDatabasePlatform()->getVarcharMaxLength()) {
196
						$column->setType(Type::getType('text'));
197
						$column->setLength(null);
198
					}
199
				}
200
			}
201
		}
202
203
		$this->connection->getConfiguration()->setSchemaAssetsFilter(function ($asset) {
204
			/** @var string|AbstractAsset $asset */
205
			$filterExpression = $this->getFilterExpression();
206
			if ($asset instanceof AbstractAsset) {
207
				return preg_match($filterExpression, $asset->getName()) !== false;
208
			}
209
			return preg_match($filterExpression, $asset) !== false;
210
		});
211
		$sourceSchema = $connection->getSchemaManager()->createSchema();
212
213
		// remove tables we don't know about
214
		foreach ($sourceSchema->getTables() as $table) {
215
			if (!$targetSchema->hasTable($table->getName())) {
216
				$sourceSchema->dropTable($table->getName());
217
			}
218
		}
219
		// remove sequences we don't know about
220
		foreach ($sourceSchema->getSequences() as $table) {
221
			if (!$targetSchema->hasSequence($table->getName())) {
222
				$sourceSchema->dropSequence($table->getName());
223
			}
224
		}
225
226
		$comparator = new Comparator();
227
		return $comparator->compare($sourceSchema, $targetSchema);
228
	}
229
230
	/**
231
	 * @param \Doctrine\DBAL\Schema\Schema $targetSchema
232
	 * @param \Doctrine\DBAL\Connection $connection
233
	 */
234
	protected function applySchema(Schema $targetSchema, \Doctrine\DBAL\Connection $connection = null) {
235
		if (is_null($connection)) {
236
			$connection = $this->connection;
237
		}
238
239
		$schemaDiff = $this->getDiff($targetSchema, $connection);
240
241
		$connection->beginTransaction();
242
		$sqls = $schemaDiff->toSql($connection->getDatabasePlatform());
243
		$step = 0;
244
		foreach ($sqls as $sql) {
245
			$this->emit($sql, $step++, count($sqls));
246
			$connection->query($sql);
247
		}
248
		$connection->commit();
249
	}
250
251
	/**
252
	 * @param string $sourceName
253
	 * @param string $targetName
254
	 */
255
	protected function copyTable($sourceName, $targetName) {
256
		$quotedSource = $this->connection->quoteIdentifier($sourceName);
257
		$quotedTarget = $this->connection->quoteIdentifier($targetName);
258
259
		$this->connection->exec('CREATE TABLE ' . $quotedTarget . ' (LIKE ' . $quotedSource . ')');
260
		$this->connection->exec('INSERT INTO ' . $quotedTarget . ' SELECT * FROM ' . $quotedSource);
261
	}
262
263
	/**
264
	 * @param string $name
265
	 */
266
	protected function dropTable($name) {
267
		$this->connection->exec('DROP TABLE ' . $this->connection->quoteIdentifier($name));
268
	}
269
270
	/**
271
	 * @param $statement
272
	 * @return string
273
	 */
274
	protected function convertStatementToScript($statement) {
275
		$script = $statement . ';';
276
		$script .= PHP_EOL;
277
		$script .= PHP_EOL;
278
		return $script;
279
	}
280
281
	protected function getFilterExpression() {
282
		return '/^' . preg_quote($this->config->getSystemValue('dbtableprefix', 'oc_')) . '/';
283
	}
284
285
	protected function emit($sql, $step, $max) {
286
		if ($this->noEmit) {
287
			return;
288
		}
289
		if (is_null($this->dispatcher)) {
290
			return;
291
		}
292
		$this->dispatcher->dispatch('\OC\DB\Migrator::executeSql', new GenericEvent($sql, [$step + 1, $max]));
0 ignored issues
show
Bug introduced by
'\OC\DB\Migrator::executeSql' of type string is incompatible with the type object expected by parameter $event of Symfony\Contracts\EventD...erInterface::dispatch(). ( Ignorable by Annotation )

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

292
		$this->dispatcher->dispatch(/** @scrutinizer ignore-type */ '\OC\DB\Migrator::executeSql', new GenericEvent($sql, [$step + 1, $max]));
Loading history...
Unused Code introduced by
The call to Symfony\Contracts\EventD...erInterface::dispatch() has too many arguments starting with new Symfony\Component\Ev...array($step + 1, $max)). ( Ignorable by Annotation )

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

292
		$this->dispatcher->/** @scrutinizer ignore-call */ 
293
                     dispatch('\OC\DB\Migrator::executeSql', new GenericEvent($sql, [$step + 1, $max]));

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
293
	}
294
295
	private function emitCheckStep($tableName, $step, $max) {
0 ignored issues
show
Unused Code introduced by
The method emitCheckStep() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
296
		if (is_null($this->dispatcher)) {
297
			return;
298
		}
299
		$this->dispatcher->dispatch('\OC\DB\Migrator::checkTable', new GenericEvent($tableName, [$step + 1, $max]));
0 ignored issues
show
Unused Code introduced by
The call to Symfony\Contracts\EventD...erInterface::dispatch() has too many arguments starting with new Symfony\Component\Ev...array($step + 1, $max)). ( Ignorable by Annotation )

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

299
		$this->dispatcher->/** @scrutinizer ignore-call */ 
300
                     dispatch('\OC\DB\Migrator::checkTable', new GenericEvent($tableName, [$step + 1, $max]));

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
Bug introduced by
'\OC\DB\Migrator::checkTable' of type string is incompatible with the type object expected by parameter $event of Symfony\Contracts\EventD...erInterface::dispatch(). ( Ignorable by Annotation )

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

299
		$this->dispatcher->dispatch(/** @scrutinizer ignore-type */ '\OC\DB\Migrator::checkTable', new GenericEvent($tableName, [$step + 1, $max]));
Loading history...
300
	}
301
}
302