Passed
Push — master ( f533bd...3986a9 )
by Ron
02:38
created

MySQL::transaction()   A

Complexity

Conditions 5
Paths 8

Size

Total Lines 23
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 9
Bugs 0 Features 1
Metric Value
eloc 19
dl 0
loc 23
rs 9.3222
c 9
b 0
f 1
cc 5
nc 8
nop 1
1
<?php
2
namespace Kir\MySQL\Databases;
3
4
use Kir\MySQL\Builder;
5
use Kir\MySQL\Builder\DBExpr;
6
use Kir\MySQL\Builder\QueryStatement;
7
use Kir\MySQL\Builder\Select;
8
use Kir\MySQL\Database;
9
use Kir\MySQL\Databases\MySQL\MySQLExceptionInterpreter;
10
use Kir\MySQL\Databases\MySQL\MySQLExpressionQuoter;
11
use Kir\MySQL\Databases\MySQL\MySQLFieldQuoter;
12
use Kir\MySQL\Databases\MySQL\MySQLRunnableSelect;
13
use Kir\MySQL\Databases\MySQL\MySQLUUIDGenerator;
14
use Kir\MySQL\Databases\MySQL\MySQLValueQuoter;
15
use Kir\MySQL\QueryLogger\QueryLoggers;
16
use Kir\MySQL\Tools\AliasRegistry;
17
use Kir\MySQL\Tools\VirtualTables;
18
use PDO;
19
use PDOException;
20
use RuntimeException;
21
use Throwable;
22
use phpDocumentor\Reflection\Types\Scalar;
23
24
/**
25
 */
26
class MySQL implements Database {
27
	/** @var array<string, array<int, string>> */
28
	private $tableFields = [];
29
	/** @var PDO */
30
	private $pdo;
31
	/** @var bool */
32
	private $outerTransaction = false;
33
	/** @var AliasRegistry */
34
	private $aliasRegistry;
35
	/** @var int */
36
	private $transactionLevel = 0;
37
	/** @var QueryLoggers */
38
	private $queryLoggers;
39
	/** @var VirtualTables */
40
	private $virtualTables;
41
	/** @var MySQLExceptionInterpreter */
42
	private $exceptionInterpreter;
43
	/** @var array<string, mixed> */
44
	private $options;
45
46
	/**
47
	 * @param PDO $pdo
48
	 * @param array<string, mixed> $options
49
	 */
50
	public function __construct(PDO $pdo, array $options = []) {
51
		if($pdo->getAttribute(PDO::ATTR_ERRMODE) === PDO::ERRMODE_SILENT) {
52
			$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
53
		}
54
		$this->pdo = $pdo;
55
		$this->aliasRegistry = new AliasRegistry();
56
		$this->queryLoggers = new QueryLoggers();
57
		$this->exceptionInterpreter = new MySQLExceptionInterpreter();
58
		$defaultOptions = [
59
			'select-options' => [],
60
			'insert-options' => [],
61
			'update-options' => [],
62
			'delete-options' => [],
63
		];
64
		$this->options = array_merge($defaultOptions, $options);
65
	}
66
67
	/**
68
	 * @return QueryLoggers
69
	 */
70
	public function getQueryLoggers(): QueryLoggers {
71
		return $this->queryLoggers;
72
	}
73
74
	/**
75
	 * @return AliasRegistry
76
	 */
77
	public function getAliasRegistry(): AliasRegistry {
78
		return $this->aliasRegistry;
79
	}
80
81
	/**
82
	 * @return VirtualTables
83
	 */
84
	public function getVirtualTables(): VirtualTables {
85
		if($this->virtualTables === null) {
86
			$this->virtualTables = new VirtualTables();
87
		}
88
		return $this->virtualTables;
89
	}
90
91
	/**
92
	 * @param string $query
93
	 * @return QueryStatement
94
	 */
95
	public function query(string $query) {
96
		return $this->buildQueryStatement($query, function ($query) {
97
			return $this->pdo->query($query);
98
		});
99
	}
100
101
	/**
102
	 * @param string $query
103
	 * @return QueryStatement
104
	 */
105
	public function prepare(string $query) {
106
		return $this->buildQueryStatement((string) $query, function ($query) {
107
			return $this->pdo->prepare($query);
108
		});
109
	}
110
111
	/**
112
	 * @param string $query
113
	 * @param array<string, mixed> $params
114
	 * @return int
115
	 */
116
	public function exec(string $query, array $params = []): int {
117
		return $this->exceptionHandler(function () use ($query, $params) {
118
			$stmt = $this->pdo->prepare($query);
119
			$timer = microtime(true);
120
			$stmt->execute($params);
121
			$this->queryLoggers->log($query, microtime(true) - $timer);
122
			$result = $stmt->rowCount();
123
			$stmt->closeCursor();
124
			return $result;
125
		});
126
	}
127
128
	/**
129
	 * @param string|null $name
130
	 * @return string
131
	 */
132
	public function getLastInsertId(?string $name = null): string {
133
		return $this->pdo->lastInsertId();
134
	}
135
136
	/**
137
	 * @param string $table
138
	 * @return array<int, string>
139
	 */
140
	public function getTableFields(string $table): array {
141
		$table = $this->select()->aliasReplacer()->replace($table);
142
		if(array_key_exists($table, $this->tableFields)) {
143
			return $this->tableFields[$table];
144
		}
145
		$stmt = $this->pdo->query("DESCRIBE {$table}");
146
		if($stmt === false) {
147
			throw new RuntimeException('Invalid return type');
148
		}
149
		$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
150
		$this->tableFields[$table] = array_map(static function ($row) { return $row['Field']; }, $rows ?: []);
151
		$stmt->closeCursor();
152
		return $this->tableFields[$table];
153
	}
154
155
	/**
156
	 * @param string $expression
157
	 * @param array<int, null|scalar|array<int, string>|DBExpr|Select> $arguments
158
	 * @return string
159
	 */
160
	public function quoteExpression(string $expression, array $arguments = []): string {
161
		return MySQLExpressionQuoter::quoteExpression($this->pdo, $expression, $arguments);
162
	}
163
164
	/**
165
	 * @param null|scalar|array<int, string>|DBExpr|Select $value
166
	 * @return string
167
	 */
168
	public function quote($value): string {
169
		return MySQLValueQuoter::quote($this->pdo, $value);
170
	}
171
172
	/**
173
	 * @param string $field
174
	 * @return string
175
	 */
176
	public function quoteField(string $field): string {
177
		return MySQLFieldQuoter::quoteField($field);
178
	}
179
180
	/**
181
	 * @param array<string|int, string>|null $fields
182
	 * @return MySQLRunnableSelect
183
	 */
184
	public function select(array $fields = null): Builder\RunnableSelect {
185
		$select = array_key_exists('select-factory', $this->options)
186
			? call_user_func($this->options['select-factory'], $this, $this->options['select-options'])
187
			: new MySQL\MySQLRunnableSelect($this, $this->options['select-options']);
188
		if($fields !== null) {
189
			$select->fields($fields);
190
		}
191
		return $select;
192
	}
193
194
	/**
195
	 * @param null|array<string|int, string> $fields
196
	 * @return Builder\RunnableInsert
197
	 */
198
	public function insert(array $fields = null): Builder\RunnableInsert {
199
		$insert = array_key_exists('insert-factory', $this->options)
200
			? call_user_func($this->options['insert-factory'], $this, $this->options['insert-options'])
201
			: new Builder\RunnableInsert($this, $this->options['insert-options']);
202
		if($fields !== null) {
203
			$insert->addAll($fields);
204
		}
205
		return $insert;
206
	}
207
208
	/**
209
	 * @param array<string|int, string>|null $fields
210
	 * @return Builder\RunnableUpdate
211
	 */
212
	public function update(array $fields = null): Builder\RunnableUpdate {
213
		$update = array_key_exists('update-factory', $this->options)
214
			? call_user_func($this->options['update-factory'], $this, $this->options['update-options'])
215
			: new Builder\RunnableUpdate($this, $this->options['update-options']);
216
		if($fields !== null) {
217
			$update->setAll($fields);
218
		}
219
		return $update;
220
	}
221
222
	/**
223
	 * @return Builder\RunnableDelete
224
	 */
225
	public function delete(): Builder\RunnableDelete {
226
		return array_key_exists('delete-factory', $this->options)
227
			? call_user_func($this->options['delete-factory'], $this, $this->options['delete-options'])
228
			: new Builder\RunnableDelete($this, $this->options['delete-options']);
229
	}
230
231
	/**
232
	 * @return $this
233
	 */
234
	public function transactionStart() {
235
		if($this->transactionLevel === 0) {
236
			if($this->pdo->inTransaction()) {
237
				$this->outerTransaction = true;
238
			} else {
239
				$this->pdo->beginTransaction();
240
			}
241
		}
242
		$this->transactionLevel++;
243
		return $this;
244
	}
245
246
	/**
247
	 * @return $this
248
	 */
249
	public function transactionCommit() {
250
		return $this->transactionEnd(function () {
251
			$this->pdo->commit();
252
		});
253
	}
254
255
	/**
256
	 * @return $this
257
	 */
258
	public function transactionRollback() {
259
		return $this->transactionEnd(function () {
260
			$this->pdo->rollBack();
261
		});
262
	}
263
264
	/**
265
	 * @template T
266
	 * @param callable(MySQL): T $callback
267
	 * @return T
268
	 */
269
	public function dryRun(callable $callback) {
270
		if(!$this->pdo->inTransaction()) {
271
			$this->transactionStart();
272
			try {
273
				return $callback($this);
274
			} finally {
275
				$this->transactionRollback();
276
			}
277
		} else {
278
			$uniqueId = MySQLUUIDGenerator::genUUIDv4();
279
			$this->exec("SAVEPOINT {$uniqueId}");
280
			try {
281
				return $callback($this);
282
			} finally {
283
				$this->exec("ROLLBACK TO {$uniqueId}");
284
			}
285
		}
286
	}
287
288
	/**
289
	 * @template T
290
	 * @param callable(MySQL): T $callback
291
	 * @return T
292
	 * @throws Throwable
293
	 */
294
	public function transaction(callable $callback) {
295
		if(!$this->pdo->inTransaction()) {
296
			$this->transactionStart();
297
			try {
298
				$result = $callback($this);
299
				$this->transactionCommit();
300
				return $result;
301
			} catch (Throwable $e) {
302
				if($this->pdo->inTransaction()) {
303
					$this->transactionRollback();
304
				}
305
				throw $e;
306
			}
307
		}
308
		$uniqueId = MySQLUUIDGenerator::genUUIDv4();
309
		$this->exec("SAVEPOINT {$uniqueId}");
310
		try {
311
			$result = $callback($this);
312
			$this->exec("RELEASE SAVEPOINT {$uniqueId}");
313
			return $result;
314
		} catch (Throwable $e) {
315
			$this->exec("ROLLBACK TO {$uniqueId}");
316
			throw $e;
317
		}
318
	}
319
320
	/**
321
	 * @param callable(): void $fn
322
	 * @return $this
323
	 */
324
	private function transactionEnd($fn): self {
325
		$this->transactionLevel--;
326
		if($this->transactionLevel < 0) {
327
			throw new RuntimeException("Transaction-Nesting-Problem: Trying to invoke commit on a already closed transaction");
328
		}
329
		if($this->transactionLevel < 1) {
330
			if($this->outerTransaction) {
331
				$this->outerTransaction = false;
332
			} else {
333
				$fn();
334
			}
335
		}
336
		return $this;
337
	}
338
339
340
	/**
341
	 * @param string $query
342
	 * @param callable $fn
343
	 * @return QueryStatement
344
	 */
345
	private function buildQueryStatement(string $query, callable $fn): QueryStatement {
346
		$stmt = $fn($query);
347
		if(!$stmt) {
348
			throw new RuntimeException("Could not execute statement:\n{$query}");
349
		}
350
		return new QueryStatement($stmt, $query, $this->exceptionInterpreter, $this->queryLoggers);
351
	}
352
353
	/**
354
	 * @param callable $fn
355
	 * @return mixed
356
	 */
357
	private function exceptionHandler(callable $fn) {
358
		try {
359
			return $fn();
360
		} catch (PDOException $e) {
361
			$this->exceptionInterpreter->throwMoreConcreteException($e);
362
		}
363
		return null;
364
	}
365
366
	/**
367
	 * @return string
368
	 */
369
	private function genUniqueId(): string {
0 ignored issues
show
Unused Code introduced by
The method genUniqueId() 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...
370
		// Generate a unique id from a former random-uuid-generator
371
		return sprintf('ID%04x%04x%04x%04x%04x%04x%04x%04x',
372
			mt_rand(0, 0xffff),
373
			mt_rand(0, 0xffff),
374
			mt_rand(0, 0xffff),
375
			mt_rand(0, 0x0fff) | 0x4000,
376
			mt_rand(0, 0x3fff) | 0x8000,
377
			mt_rand(0, 0xffff),
378
			mt_rand(0, 0xffff),
379
			mt_rand(0, 0xffff)
380
		);
381
	}
382
}
383