MySQL::getTableFields()   A
last analyzed

Complexity

Conditions 6
Paths 2

Size

Total Lines 22
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

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