Passed
Push — master ( b953c9...de8e3d )
by Ron
02:53
created

MySQL::genUniqueId()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
eloc 9
c 2
b 1
f 0
dl 0
loc 11
rs 9.9666
cc 1
nc 1
nop 0

1 Method

Rating   Name   Duplication   Size   Complexity  
A MySQL::exceptionHandler() 0 5 2
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->getQueryLoggers()->logRegion($query, function() use ($query) {
97
			return $this->buildQueryStatement($query, function ($query) {
98
				return $this->pdo->query($query);
99
			});
100
		});
101
	}
102
103
	/**
104
	 * @param string $query
105
	 * @return QueryStatement
106
	 */
107
	public function prepare(string $query) {
108
		return $this->buildQueryStatement((string) $query, function ($query) {
109
			return $this->pdo->prepare($query);
110
		});
111
	}
112
113
	/**
114
	 * @param string $query
115
	 * @param array<string, mixed> $params
116
	 * @return int
117
	 */
118
	public function exec(string $query, array $params = []): int {
119
		return $this->getQueryLoggers()->logRegion($query, function() use ($query, $params) {
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->getQueryLo...ion(...) { /* ... */ }) returns the type Kir\MySQL\QueryLogger\T which is incompatible with the type-hinted return integer.
Loading history...
120
			return $this->exceptionHandler(function () use ($query, $params) {
121
				$stmt = $this->pdo->prepare($query);
122
				$timer = microtime(true);
123
				$stmt->execute($params);
124
				$this->queryLoggers->log($query, microtime(true) - $timer);
125
				$result = $stmt->rowCount();
126
				$stmt->closeCursor();
127
				return $result;
128
			});
129
		});
130
	}
131
132
	/**
133
	 * @param string|null $name
134
	 * @return string
135
	 */
136
	public function getLastInsertId(?string $name = null): string {
137
		return $this->pdo->lastInsertId();
138
	}
139
140
	/**
141
	 * @param string $table
142
	 * @return array<int, string>
143
	 */
144
	public function getTableFields(string $table): array {
145
		$fqTable = $this->select()->aliasReplacer()->replace($table);
146
		if(array_key_exists($fqTable, $this->tableFields)) {
147
			return $this->tableFields[$fqTable];
148
		}
149
		$query = "DESCRIBE {$fqTable}";
150
		return $this->getQueryLoggers()->logRegion($query, function() use ($query, $fqTable) {
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->getQueryLo...ion(...) { /* ... */ }) returns the type Kir\MySQL\QueryLogger\T which is incompatible with the type-hinted return array.
Loading history...
151
			return $this->exceptionHandler(function () use ($query, $fqTable) {
152
				$stmt = $this->pdo->query($query);
153
				try {
154
					if($stmt === false) {
155
						throw new RuntimeException('Invalid return type');
156
					}
157
					$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
158
					$this->tableFields[$fqTable] = array_map(static function ($row) { return $row['Field']; }, $rows ?: []);
159
					return $this->tableFields[$fqTable];
160
				} finally {
161
					try {
162
						$stmt->closeCursor();
163
					} catch (Throwable $e) {}
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
164
				}
165
			});
166
		});
167
	}
168
169
	/**
170
	 * @param string $expression
171
	 * @param array<int, null|scalar|array<int, string>|DBExpr|Select> $arguments
172
	 * @return string
173
	 */
174
	public function quoteExpression(string $expression, array $arguments = []): string {
175
		return MySQLExpressionQuoter::quoteExpression($this->pdo, $expression, $arguments);
176
	}
177
178
	/**
179
	 * @param null|scalar|array<int, string>|DBExpr|Select $value
180
	 * @return string
181
	 */
182
	public function quote($value): string {
183
		return MySQLValueQuoter::quote($this->pdo, $value);
184
	}
185
186
	/**
187
	 * @param string $field
188
	 * @return string
189
	 */
190
	public function quoteField(string $field): string {
191
		return MySQLFieldQuoter::quoteField($field);
192
	}
193
194
	/**
195
	 * @param array<string|int, string>|null $fields
196
	 * @return MySQLRunnableSelect
197
	 */
198
	public function select(array $fields = null): Builder\RunnableSelect {
199
		$select = array_key_exists('select-factory', $this->options)
200
			? call_user_func($this->options['select-factory'], $this, $this->options['select-options'])
201
			: new MySQL\MySQLRunnableSelect($this, $this->options['select-options']);
202
		if($fields !== null) {
203
			$select->fields($fields);
204
		}
205
		return $select;
206
	}
207
208
	/**
209
	 * @param null|array<string|int, string> $fields
210
	 * @return Builder\RunnableInsert
211
	 */
212
	public function insert(array $fields = null): Builder\RunnableInsert {
213
		$insert = array_key_exists('insert-factory', $this->options)
214
			? call_user_func($this->options['insert-factory'], $this, $this->options['insert-options'])
215
			: new Builder\RunnableInsert($this, $this->options['insert-options']);
216
		if($fields !== null) {
217
			$insert->addAll($fields);
218
		}
219
		return $insert;
220
	}
221
222
	/**
223
	 * @param array<string|int, string>|null $fields
224
	 * @return Builder\RunnableUpdate
225
	 */
226
	public function update(array $fields = null): Builder\RunnableUpdate {
227
		$update = array_key_exists('update-factory', $this->options)
228
			? call_user_func($this->options['update-factory'], $this, $this->options['update-options'])
229
			: new Builder\RunnableUpdate($this, $this->options['update-options']);
230
		if($fields !== null) {
231
			$update->setAll($fields);
232
		}
233
		return $update;
234
	}
235
236
	/**
237
	 * @return Builder\RunnableDelete
238
	 */
239
	public function delete(): Builder\RunnableDelete {
240
		return array_key_exists('delete-factory', $this->options)
241
			? call_user_func($this->options['delete-factory'], $this, $this->options['delete-options'])
242
			: new Builder\RunnableDelete($this, $this->options['delete-options']);
243
	}
244
245
	/**
246
	 * @return $this
247
	 */
248
	public function transactionStart() {
249
		if($this->transactionLevel === 0) {
250
			if($this->pdo->inTransaction()) {
251
				$this->outerTransaction = true;
252
			} else {
253
				$this->pdo->beginTransaction();
254
			}
255
		}
256
		$this->transactionLevel++;
257
		return $this;
258
	}
259
260
	/**
261
	 * @return $this
262
	 */
263
	public function transactionCommit() {
264
		return $this->transactionEnd(function () {
265
			$this->pdo->commit();
266
		});
267
	}
268
269
	/**
270
	 * @return $this
271
	 */
272
	public function transactionRollback() {
273
		return $this->transactionEnd(function () {
274
			$this->pdo->rollBack();
275
		});
276
	}
277
278
	/**
279
	 * @template T
280
	 * @param callable(MySQL): T $callback
281
	 * @return T
282
	 */
283
	public function dryRun(callable $callback) {
284
		if(!$this->pdo->inTransaction()) {
285
			$this->transactionStart();
286
			try {
287
				return $callback($this);
288
			} finally {
289
				$this->transactionRollback();
290
			}
291
		} else {
292
			$uniqueId = MySQLUUIDGenerator::genUUIDv4();
293
			$this->exec("SAVEPOINT {$uniqueId}");
294
			try {
295
				return $callback($this);
296
			} finally {
297
				$this->exec("ROLLBACK TO {$uniqueId}");
298
			}
299
		}
300
	}
301
302
	/**
303
	 * @template T
304
	 * @param callable(MySQL): T $callback
305
	 * @return T
306
	 * @throws Throwable
307
	 */
308
	public function transaction(callable $callback) {
309
		if(!$this->pdo->inTransaction()) {
310
			$this->transactionStart();
311
			try {
312
				$result = $callback($this);
313
				$this->transactionCommit();
314
				return $result;
315
			} catch (Throwable $e) {
316
				if($this->pdo->inTransaction()) {
317
					$this->transactionRollback();
318
				}
319
				throw $e;
320
			}
321
		}
322
		$uniqueId = MySQLUUIDGenerator::genUUIDv4();
323
		$this->exec("SAVEPOINT {$uniqueId}");
324
		try {
325
			$result = $callback($this);
326
			$this->exec("RELEASE SAVEPOINT {$uniqueId}");
327
			return $result;
328
		} catch (Throwable $e) {
329
			$this->exec("ROLLBACK TO {$uniqueId}");
330
			throw $e;
331
		}
332
	}
333
334
	/**
335
	 * @param callable(): void $fn
336
	 * @return $this
337
	 */
338
	private function transactionEnd($fn): self {
339
		$this->transactionLevel--;
340
		if($this->transactionLevel < 0) {
341
			throw new RuntimeException("Transaction-Nesting-Problem: Trying to invoke commit on a already closed transaction");
342
		}
343
		if($this->transactionLevel < 1) {
344
			if($this->outerTransaction) {
345
				$this->outerTransaction = false;
346
			} else {
347
				$fn();
348
			}
349
		}
350
		return $this;
351
	}
352
353
354
	/**
355
	 * @param string $query
356
	 * @param callable $fn
357
	 * @return QueryStatement
358
	 */
359
	private function buildQueryStatement(string $query, callable $fn): QueryStatement {
360
		$stmt = $fn($query);
361
		if(!$stmt) {
362
			throw new RuntimeException("Could not execute statement:\n{$query}");
363
		}
364
		return new QueryStatement($stmt, $query, $this->exceptionInterpreter, $this->queryLoggers);
365
	}
366
367
	/**
368
	 * @template T
369
	 * @param callable(): T $fn
370
	 * @return T
371
	 */
372
	private function exceptionHandler(callable $fn) {
373
		try {
374
			return $fn();
375
		} catch (PDOException $exception) {
376
			throw $this->exceptionInterpreter->getMoreConcreteException($exception);
377
		}
378
	}
379
}
380