MySQL::exec()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 15
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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