Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.96% covered (success)
90.96%
161 / 177
73.68% covered (warning)
73.68%
28 / 38
CRAP
0.00% covered (danger)
0.00%
0 / 1
DatabaseContext
90.96% covered (success)
90.96%
161 / 177
73.68% covered (warning)
73.68%
28 / 38
84.73
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getErrorMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setParameters
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 executeStatement
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 getColumns
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 convertRowResult
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 convertTableResult
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 convertSequenceResult
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 disposeImpl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 inTransaction
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 beginTransaction
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 commit
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 rollback
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 transaction
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
3.43
 escapeLike
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 escapeValue
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 fetch
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 query
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 queryFirst
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 queryFirstOrNull
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 querySingle
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 querySingleOrNull
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 throwIfInvalidOrdered
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 selectOrdered
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 throwIfInvalidSingleCount
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 selectSingleCount
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 execute
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 throwIfInvalidInsert
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 insert
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 insertSingle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 throwIfInvalidUpdate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 update
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 updateByKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 updateByKeyOrNothing
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 throwIfInvalidDelete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deleteByKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 deleteByKeyOrNothing
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace PeServer\Core\Database;
6
7use Exception;
8use PDO;
9use PDOException;
10use PDOStatement;
11use Throwable;
12use PeServer\Core\Collection\Arr;
13use PeServer\Core\Database\ConnectionSetting;
14use PeServer\Core\Database\DatabaseColumn;
15use PeServer\Core\Database\DatabaseSequenceResult;
16use PeServer\Core\Database\DatabaseTableResult;
17use PeServer\Core\Database\IDatabaseTransactionContext;
18use PeServer\Core\DisposerBase;
19use PeServer\Core\Log\ILogger;
20use PeServer\Core\Regex;
21use PeServer\Core\Text;
22use PeServer\Core\Throws\DatabaseException;
23use PeServer\Core\Throws\NotImplementedException;
24use PeServer\Core\Throws\SqlException;
25use PeServer\Core\Throws\Throws;
26use PeServer\Core\Throws\TransactionException;
27use PeServer\Core\TypeUtility;
28
29/**
30 * DB処理。
31 */
32class DatabaseContext extends DisposerBase implements IDatabaseTransactionContext
33{
34    #region variable
35
36    /**
37     * 接続処理。
38     */
39    protected readonly PDO $pdo;
40
41    /**
42     * ロガー
43     */
44    protected readonly ILogger $logger;
45
46    protected Regex $regex;
47
48    #endregion
49
50    /**
51     * 生成。
52     *
53     * @param ConnectionSetting $setting
54     * @param ILogger $logger
55     * @throws DatabaseException
56     */
57    public function __construct(ConnectionSetting $setting, ILogger $logger)
58    {
59        $this->logger = $logger;
60        $this->regex = new Regex();
61
62        $this->pdo = Throws::wrap(PDOException::class, DatabaseException::class, fn () => new PDO($setting->dsn, $setting->user, $setting->password, $setting->options));
63        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); //cspell:disable-line
64        $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); //cspell:disable-line
65    }
66
67    #region function
68
69    /**
70     * 直近のエラーメッセージを取得。
71     *
72     * @return string
73     */
74    private function getErrorMessage(): string
75    {
76        return Text::dump($this->pdo->errorInfo());
77    }
78
79    /**
80     * バインド実行。
81     *
82     * @param PDOStatement $statement
83     * @param array<string|int,string|int|bool>|null $parameters
84     * @phpstan-param array<array-key,DatabaseBindValueAlias>|null $parameters
85     * @return void
86     */
87    private function setParameters(PDOStatement $statement, ?array $parameters): void
88    {
89        if ($parameters !== null) {
90            foreach ($parameters as $key => $value) {
91                if (!$statement->bindValue($key, $value)) {
92                    throw new SqlException('$key: ' . $key . ' -> ' . TypeUtility::getType($value));
93                }
94            }
95        }
96    }
97
98    /**
99     * 文を実行。
100     *
101     * @param string $statement
102     * @phpstan-param literal-string $statement
103     * @phpstan-param array<array-key,DatabaseBindValueAlias>|null $parameters
104     * @return PDOStatement
105     * @throws SqlException 実行失敗。
106     */
107    private function executeStatement(string $statement, ?array $parameters): PDOStatement
108    {
109        $this->throwIfDisposed();
110
111        /** @var PDOStatement|false|null */
112        $query = null;
113
114        try {
115            $query = $this->pdo->prepare($statement);
116            if ($query === false) {
117                throw new SqlException($this->getErrorMessage());
118            }
119
120            $this->setParameters($query, $parameters);
121        } catch (PDOException $ex) {
122            Throws::reThrow(SqlException::class, $ex);
123        }
124
125        $this->logger->trace($query, $parameters);
126
127        try {
128            if (!$query->execute()) {
129                throw new DatabaseException($this->getErrorMessage());
130            }
131        } catch (PDOException $ex) {
132            Throws::reThrow(DatabaseException::class, $ex);
133        }
134
135        return $query;
136    }
137
138    /**
139     * カラム情報一覧の取得。
140     *
141     * カラム情報は取得できたものだけを返す。
142     *
143     * @param PDOStatement $pdoStatement
144     * @return DatabaseColumn[] 取得できたカラム一覧。
145     */
146    private function getColumns(PDOStatement $pdoStatement): array
147    {
148        $count = $pdoStatement->columnCount();
149        if ($count === 0) {
150            return [];
151        }
152
153        $columns = [];
154
155        for ($i = 0; $i < $count; $i++) {
156            $meta = $pdoStatement->getColumnMeta($i);
157            if ($meta === false) {
158                continue;
159            }
160
161            $column = DatabaseColumn::create($meta);
162            $columns[] = $column;
163        }
164
165        return $columns;
166    }
167
168    /**
169     * 単一行データに変換。
170     *
171     * データが存在しない場合、`DatabaseRowResult->field` はから配列となるが、あくまで `Database` 内限定のデータ状態となる。
172     *
173     * @template TFieldArray of FieldArrayAlias
174     * @param PDOStatement $pdoStatement
175     * @return DatabaseRowResult
176     * @phpstan-return DatabaseRowResult<TFieldArray>
177     */
178    private function convertRowResult(PDOStatement $pdoStatement): DatabaseRowResult
179    {
180        $columns = $this->getColumns($pdoStatement);
181
182        $resultCount = $pdoStatement->rowCount();
183
184        /** @phpstan-var TFieldArray|false */
185        $row = $pdoStatement->fetch();
186        if ($row === false) {
187            return new DatabaseRowResult($columns, $resultCount, []); //@phpstan-ignore-line 空データを本クラス内のみ許容
188        }
189
190        return new DatabaseRowResult($columns, $resultCount, $row); //@phpstan-ignore-line 空じゃないでしょ・・・
191    }
192
193    /**
194     * データセットに変換。
195     *
196     * @template TFieldArray of FieldArrayAlias
197     * @param PDOStatement $pdoStatement
198     * @return DatabaseTableResult
199     * @phpstan-return DatabaseTableResult<TFieldArray>
200     */
201    private function convertTableResult(PDOStatement $pdoStatement): DatabaseTableResult
202    {
203        $columns = $this->getColumns($pdoStatement);
204
205        $resultCount = $pdoStatement->rowCount();
206
207        $rows = $pdoStatement->fetchAll();
208        // @phpstan-ignore-next-line: [PHP_VERSION]
209        if ($rows === false) {
210            throw new DatabaseException($this->getErrorMessage());
211        }
212
213        $result = new DatabaseTableResult($columns, $resultCount, $rows);
214
215        return $result; //@phpstan-ignore-line 空フィールドはない
216    }
217
218    /**
219     * 逐次データセットに変換。
220     *
221     * @template TFieldArray of FieldArrayAlias
222     * @param PDOStatement $pdoStatement
223     * @return DatabaseSequenceResult
224     * @phpstan-return DatabaseSequenceResult<TFieldArray>
225     */
226    private function convertSequenceResult(PDOStatement $pdoStatement): DatabaseSequenceResult
227    {
228        $columns = $this->getColumns($pdoStatement);
229
230        /** @var DatabaseSequenceResult<TFieldArray> */
231        $result = new DatabaseSequenceResult($columns, $pdoStatement);
232
233        return $result;
234    }
235
236    #endregion
237
238    #region DisposerBase
239
240    protected function disposeImpl(): void
241    {
242        if ($this->inTransaction()) {
243            $this->rollback();
244        }
245
246        parent::disposeImpl();
247    }
248
249    #endregion
250
251    #region IDatabaseTransactionContext
252
253    public function inTransaction(): bool
254    {
255        $this->throwIfDisposed();
256
257        return $this->pdo->inTransaction();
258    }
259
260    public function beginTransaction(): void
261    {
262        $this->throwIfDisposed();
263
264        if ($this->inTransaction()) {
265            throw new TransactionException();
266        }
267
268        try {
269            if (!$this->pdo->beginTransaction()) {
270                throw new TransactionException($this->getErrorMessage());
271            }
272        } catch (PDOException $ex) {
273            Throws::reThrow(TransactionException::class, $ex, $this->getErrorMessage());
274        }
275    }
276
277    public function commit(): void
278    {
279        $this->throwIfDisposed();
280
281        if (!$this->inTransaction()) {
282            throw new TransactionException();
283        }
284
285        try {
286            if (!$this->pdo->commit()) {
287                throw new TransactionException($this->getErrorMessage());
288            }
289        } catch (PDOException $ex) {
290            Throws::reThrow(TransactionException::class, $ex, $this->getErrorMessage());
291        }
292    }
293
294    public function rollback(): void
295    {
296        if (!$this->inTransaction()) {
297            throw new TransactionException();
298        }
299
300        try {
301            if (!$this->pdo->rollBack()) {
302                throw new TransactionException($this->getErrorMessage());
303            }
304        } catch (PDOException $ex) {
305            Throws::reThrow(TransactionException::class, $ex, $this->getErrorMessage());
306        }
307    }
308
309    public function transaction(callable $callback): bool
310    {
311        try {
312            $this->beginTransaction();
313
314            $result = $callback($this);
315            if ($result) {
316                $this->commit();
317                return true;
318            } else {
319                $this->rollback();
320            }
321        } catch (Throwable $ex) {
322            $this->logger->error($ex);
323            $this->rollback();
324            Throws::reThrow(DatabaseException::class, $ex);
325        }
326
327        return false;
328    }
329
330    public function escapeLike(string $value): string
331    {
332        return Text::replace(
333            $value,
334            ["\\", '%', '_'],
335            ["\\\\", '\\%', '\\_'],
336        );
337    }
338
339    public function escapeValue(mixed $value): string
340    {
341        if ($value === null) {
342            return 'null';
343        }
344
345        return $this->pdo->quote($value);
346    }
347
348    /**
349     * @template TFieldArray of FieldArrayAlias
350     * @phpstan-return DatabaseSequenceResult<TFieldArray>
351     */
352    public function fetch(string $statement, ?array $parameters = null): DatabaseSequenceResult
353    {
354        $query = $this->executeStatement($statement, $parameters);
355
356        /** @phpstan-var DatabaseSequenceResult<TFieldArray> */
357        $result = $this->convertSequenceResult($query);
358
359        return $result;
360    }
361
362    /**
363     * @template TFieldArray of FieldArrayAlias
364     * @phpstan-return DatabaseTableResult<TFieldArray>
365     */
366    public function query(string $statement, ?array $parameters = null): DatabaseTableResult
367    {
368        $query = $this->executeStatement($statement, $parameters);
369
370        /** @phpstan-var DatabaseTableResult<TFieldArray> */
371        $result = $this->convertTableResult($query);
372
373        return $result;
374    }
375
376    /**
377     * @template TFieldArray of FieldArrayAlias
378     * @phpstan-return DatabaseRowResult<TFieldArray>
379     */
380    public function queryFirst(string $statement, ?array $parameters = null): DatabaseRowResult
381    {
382        $query = $this->executeStatement($statement, $parameters);
383
384        /** @phpstan-var DatabaseRowResult<TFieldArray> */
385        $result = $this->convertRowResult($query);
386        if (Arr::isNullOrEmpty($result->fields)) { //@phpstan-ignore-line 本クラス内限定で空の可能性あり
387            throw new DatabaseException($this->getErrorMessage());
388        }
389
390        return $result;
391    }
392
393    /**
394     * @template TFieldArray of FieldArrayAlias
395     * @phpstan-return DatabaseRowResult<TFieldArray>
396     */
397    public function queryFirstOrNull(string $statement, ?array $parameters = null): ?DatabaseRowResult
398    {
399        $query = $this->executeStatement($statement, $parameters);
400
401        /** @phpstan-var DatabaseRowResult<TFieldArray> */
402        $result = $this->convertRowResult($query);
403        if (Arr::isNullOrEmpty($result->fields)) { //@phpstan-ignore-line 本クラス内限定で空の可能性あり
404            return null;
405        }
406
407        return $result;
408    }
409
410    /**
411     * @template TFieldArray of FieldArrayAlias
412     * @phpstan-return DatabaseRowResult<TFieldArray>
413     */
414    public function querySingle(string $statement, ?array $parameters = null): DatabaseRowResult
415    {
416        $query = $this->executeStatement($statement, $parameters);
417
418        /** @phpstan-var DatabaseRowResult<TFieldArray> */
419        $result = $this->convertRowResult($query);
420        if (Arr::isNullOrEmpty($result->fields)) { //@phpstan-ignore-line 本クラス内限定で空の可能性あり
421            throw new DatabaseException($this->getErrorMessage());
422        }
423
424        $next = $query->fetch();
425        if ($next !== false) {
426            throw new DatabaseException($this->getErrorMessage());
427        }
428
429        return $result;
430    }
431
432    /**
433     * @template TFieldArray of FieldArrayAlias
434     * @phpstan-return DatabaseRowResult<TFieldArray>
435     */
436    public function querySingleOrNull(string $statement, ?array $parameters = null): ?DatabaseRowResult
437    {
438        $query = $this->executeStatement($statement, $parameters);
439
440        /** @phpstan-var DatabaseRowResult<TFieldArray> */
441        $result = $this->convertRowResult($query);
442        if (Arr::isNullOrEmpty($result->fields)) { //@phpstan-ignore-line 本クラス内限定で空の可能性あり
443            return null;
444        }
445
446        $next = $query->fetch();
447        if ($next !== false) {
448            return null;
449        }
450
451        return $result;
452    }
453
454    /**
455     * ソートを強制。
456     *
457     * 単純な文字列処理のため無理な時は無理。
458     *
459     * @param string $statement
460     * @return void
461     */
462    protected function throwIfInvalidOrdered(string $statement): void
463    {
464        if (!$this->regex->isMatch($statement, '/\\border\\s+by\\b/i')) {
465            throw new SqlException('order by');
466        }
467    }
468
469    /**
470     * @template TFieldArray of FieldArrayAlias
471     * @phpstan-return DatabaseTableResult<TFieldArray>
472     */
473    public function selectOrdered(string $statement, ?array $parameters = null): DatabaseTableResult
474    {
475        $this->throwIfInvalidOrdered($statement);
476
477        /** @phpstan-var DatabaseTableResult<TFieldArray> */
478        $result = $this->query($statement, $parameters);
479
480        return $result;
481    }
482
483    /**
484     * 単独件数取得を強制。
485     *
486     * 単純な文字列処理のため無理な時は無理。
487     *
488     * @param string $statement
489     * @return void
490     */
491    protected function throwIfInvalidSingleCount(string $statement): void
492    {
493        if (!$this->regex->isMatch($statement, '/\\bselect\\s+count\\s*\\(/i')) {
494            throw new SqlException('select count');
495        }
496    }
497
498    public function selectSingleCount(string $statement, ?array $parameters = null): int
499    {
500        $this->throwIfInvalidSingleCount($statement);
501
502        /** @-var array<string,mixed> */
503        $result = $this->queryFirst($statement, $parameters);
504        $val = strval(current($result->fields));
505        if (TypeUtility::tryParseInteger($val, $count)) {
506            /** @var non-negative-int */
507            return $count;
508        }
509
510        throw new DatabaseException();
511    }
512
513    /**
514     * @template TFieldArray of FieldArrayAlias
515     * @phpstan-return DatabaseTableResult<TFieldArray>
516     */
517    public function execute(string $statement, ?array $parameters = null): DatabaseTableResult
518    {
519        $query = $this->executeStatement($statement, $parameters);
520
521        /** @phpstan-var DatabaseTableResult<TFieldArray> */
522        $result = $this->convertTableResult($query);
523
524        return $result;
525    }
526
527    /**
528     * INSERT文を強制。
529     *
530     * 単純な文字列処理のため無理な時は無理。
531     *
532     * @param string $statement
533     * @return void
534     */
535    protected function throwIfInvalidInsert(string $statement): void
536    {
537        if (!$this->regex->isMatch($statement, '/\\binsert\\b/i')) {
538            throw new SqlException('insert');
539        }
540    }
541
542    public function insert(string $statement, ?array $parameters = null): int
543    {
544        $this->throwIfInvalidInsert($statement);
545        return $this->execute($statement, $parameters)->getResultCount();
546    }
547
548    public function insertSingle(string $statement, ?array $parameters = null): void
549    {
550        $this->throwIfInvalidInsert($statement);
551        $result = $this->execute($statement, $parameters);
552        if ($result->getResultCount() !== 1) {
553            throw new DatabaseException();
554        }
555    }
556
557    /**
558     * UPDATE文を強制。
559     *
560     * 単純な文字列処理のため無理な時は無理。
561     *
562     * @param string $statement
563     * @return void
564     */
565    protected function throwIfInvalidUpdate(string $statement): void
566    {
567        if (!$this->regex->isMatch($statement, '/\\bupdate\\b/i')) {
568            throw new SqlException('update');
569        }
570    }
571
572    public function update(string $statement, ?array $parameters = null): int
573    {
574        $this->throwIfInvalidUpdate($statement);
575        return $this->execute($statement, $parameters)->getResultCount();
576    }
577
578    public function updateByKey(string $statement, ?array $parameters = null): void
579    {
580        $this->throwIfInvalidUpdate($statement);
581        $result = $this->execute($statement, $parameters);
582        if ($result->getResultCount() !== 1) {
583            throw new DatabaseException();
584        }
585    }
586
587    public function updateByKeyOrNothing(string $statement, ?array $parameters = null): bool
588    {
589        $this->throwIfInvalidUpdate($statement);
590        $result = $this->execute($statement, $parameters);
591        if (1 < $result->getResultCount()) {
592            throw new DatabaseException();
593        }
594
595        return $result->getResultCount() === 1;
596    }
597
598    /**
599     * DELETE文を強制。
600     *
601     * 単純な文字列処理のため無理な時は無理。
602     *
603     * @param string $statement
604     * @return void
605     */
606    protected function throwIfInvalidDelete(string $statement): void
607    {
608        if (!$this->regex->isMatch($statement, '/\\bdelete\\b/i')) {
609            throw new SqlException('delete');
610        }
611    }
612
613    public function delete(string $statement, ?array $parameters = null): int
614    {
615        $this->throwIfInvalidDelete($statement);
616        return $this->execute($statement, $parameters)->getResultCount();
617    }
618
619    public function deleteByKey(string $statement, ?array $parameters = null): void
620    {
621        $this->throwIfInvalidDelete($statement);
622        $result = $this->execute($statement, $parameters);
623        if ($result->getResultCount() !== 1) {
624            throw new DatabaseException();
625        }
626    }
627
628    public function deleteByKeyOrNothing(string $statement, ?array $parameters = null): bool
629    {
630        $this->throwIfInvalidDelete($statement);
631        $result = $this->execute($statement, $parameters);
632        if (1 < $result->getResultCount()) {
633            throw new DatabaseException();
634        }
635
636        return $result->getResultCount() === 1;
637    }
638
639    #endregion
640}