Issues (83)

src/Databases/MySQL.php (3 issues)

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