Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
205 / 205
100.00% covered (success)
100.00%
28 / 28
CRAP
100.00% covered (success)
100.00%
1 / 1
Mydb
100.00% covered (success)
100.00%
205 / 205
100.00% covered (success)
100.00%
28 / 28
95
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 __destruct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 open
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 query
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 command
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getEnumValues
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 escape
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getPrimaryKeys
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 beginTransaction
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 rollbackTransaction
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 commitTransaction
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 close
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
10
 replace
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 insert
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 select
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 update
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 deleteWhere
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 updateWhere
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 updateWhereMany
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 insertMany
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 replaceOne
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 insertOne
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 sendClientRequest
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 readServerResponse
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 onWarning
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onError
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 connect
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
1 / 1
17
1<?php
2/**
3 * This file is part of the sshilko/php-sql-mydb package.
4 *
5 * (c) Sergei Shilko <contact@sshilko.com>
6 *
7 * MIT License
8 *
9 * For the full copyright and license information, please view the LICENSE
10 * file that was distributed with this source code.
11 * @license https://opensource.org/licenses/mit-license.php MIT
12 */
13
14declare(strict_types = 1);
15
16namespace sql;
17
18use Psr\Log\LoggerInterface;
19use sql\MydbException\ConnectDefaultsException;
20use sql\MydbException\ConnectException;
21use sql\MydbException\DeleteException;
22use sql\MydbException\DisconnectException;
23use sql\MydbException\InternalException;
24use sql\MydbException\ServerGoneException;
25use sql\MydbException\TerminationSignalException;
26use sql\MydbException\TransactionAutocommitException;
27use sql\MydbException\TransactionBeginReadonlyException;
28use sql\MydbException\TransactionBeginReadwriteException;
29use sql\MydbException\TransactionCommitException;
30use sql\MydbException\TransactionIsolationException;
31use sql\MydbException\TransactionRollbackException;
32use sql\MydbException\UpdateException;
33use sql\MydbInterface\RemoteResourceInterface;
34use sql\MydbListener\InternalListener;
35use sql\MydbMysqli\MydbMysqliResultInterface;
36use Throwable;
37use function array_map;
38use function count;
39use function explode;
40use function implode;
41use function preg_replace;
42use function sprintf;
43use function stripos;
44use function strpos;
45use function substr;
46
47/**
48 * @author Sergei Shilko <contact@sshilko.com>
49 * @license https://opensource.org/licenses/mit-license.php MIT
50 * @see https://github.com/sshilko/php-sql-mydb
51 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
52 */
53class Mydb implements MydbInterface, RemoteResourceInterface
54{
55
56    protected MydbMysqliInterface $mysqli;
57
58    protected MydbOptionsInterface $options;
59
60    protected MydbEnvironmentInterface $environment;
61
62    protected MydbQueryBuilderInterface $queryBuilder;
63
64    protected MydbListenerInterface $eventListener;
65
66    protected bool $terminating = false;
67
68    public function __construct(
69        protected MydbCredentialsInterface $credentials,
70        protected LoggerInterface $logger,
71        ?MydbOptionsInterface $options = null,
72        ?MydbMysqliInterface $mysqli = null,
73        ?MydbEnvironmentInterface $environment = null,
74        ?MydbQueryBuilderInterface $queryBuilder = null,
75        ?MydbListenerInterface $eventListener = null,
76    ) {
77        $this->options = $options ?? new MydbOptions();
78        $this->mysqli = $mysqli ?? new MydbMysqli();
79        $this->environment = $environment ?? new MydbEnvironment();
80        $this->queryBuilder = $queryBuilder ?? new MydbQueryBuilder($this->mysqli);
81        $this->eventListener = $eventListener ?? new InternalListener($logger);
82    }
83
84    /**
85     * A destructor may be called as soon as there are no references to an object.
86     *
87     * @see http://php.net/manual/en/mysqli.close.php
88     * @see http://php.net/manual/en/mysqli.ping.php (MysqlND not supports reconnect)
89     * @throws \sql\MydbException
90     */
91    public function __destruct()
92    {
93        $this->terminating = true;
94        $this->close();
95    }
96
97    /**
98     * Open connection to remote server
99     * @param int $retry retry failed connection attempts
100     * @throws \sql\MydbException
101     */
102    public function open(int $retry = 0): bool
103    {
104        return $this->connect($retry);
105    }
106
107    /**
108     * Execute raw SQL query and return results
109     *
110     * @phpcs:disable SlevomatCodingStandard.Complexity.Cognitive
111     * @phpcs:disable SlevomatCodingStandard.TypeHints.ReturnTypeHint
112     *
113     * @psalm-return array<array-key, array<array-key, (float|int|string|null)>>|null
114     * @throws \sql\MydbException\ConnectException
115     * @throws \sql\MydbException
116     */
117    public function query(string $query): ?array
118    {
119
120        if (!$this->connect()) {
121            throw new ConnectException();
122        }
123
124        $result = $this->sendClientRequest($query);
125        /**
126         * We should always read server response, no matter whether sendClientRequest result
127         */
128        $packet = $this->readServerResponse($query);
129
130        if (false === $result || null === $packet) {
131            return null;
132        }
133
134        if ($packet->getFieldCount() > 0) {
135            $payload = $packet->getResult();
136            if (null === $payload) {
137                $this->onError(
138                    new InternalException($packet->getError() ?? 'Reading of the result set failed'),
139                    $query
140                );
141            }
142
143            /**
144             * @var array<array-key, array<array-key, (float|int|string|null)>> $payload
145             */
146            return $payload;
147        }
148
149        return null;
150    }
151
152    /**
153     * @phpcs:disable SlevomatCodingStandard.Complexity.Cognitive
154     * @throws \sql\MydbException
155     */
156    public function command(string $query): bool
157    {
158        if (!$this->connect()) {
159            throw new ConnectException();
160        }
161
162
163        $result = $this->sendClientRequest($query);
164
165        if (false === $result) {
166            return false;
167        }
168
169        $packet = $this->readServerResponse($query);
170
171        return null !== $packet;
172    }
173
174    /**
175     * @return array<string>
176     * @throws \sql\MydbException
177     * @psalm-return list<string>
178     */
179    public function getEnumValues(string $table, string $column): array
180    {
181        $query = $this->queryBuilder->showColumnsLike($table, $column);
182
183        $resultArray = $this->query($query);
184        $result = isset($resultArray[0]['Type'])
185                ? (string) $resultArray[0]['Type']
186                : null;
187
188        $match = false;
189        $types = ['enum', 'set'];
190        foreach ($types as $type) {
191            if (0 === stripos((string)$result, $type . '(')) {
192                $match = $type;
193
194                break;
195            }
196        }
197
198        if (false === $match) {
199            $this->onError(new MydbException("Column not of type '" . implode(',', $types) . "'"));
200        }
201
202        $input = substr((string) $result, (int) strpos((string) $result, '(') + 1, -1);
203
204        // @codeCoverageIgnoreStart
205        /**
206         * @psalm-suppress DocblockTypeContradiction
207         */
208        if ('' === $input) {
209            throw new MydbException();
210        }
211        // @codeCoverageIgnoreEnd
212
213        $values = explode(',', preg_replace("/'/", '', $input));
214
215        return array_map('strval', $values);
216    }
217
218    /**
219     * @param float|int|string|\sql\MydbExpressionInterface|null $unescaped
220     * @throws \sql\MydbException\ConnectException
221     * @throws \sql\MydbException
222     * @SuppressWarnings(PHPMD.NPathComplexity)
223     * @todo reduce NPathComplexity
224     */
225    public function escape($unescaped, string $quote = "'"): string
226    {
227        if (!$this->connect()) {
228            throw new ConnectException();
229        }
230
231        return $this->queryBuilder->escape($unescaped, $quote);
232    }
233
234    /**
235     * @throws \sql\MydbException
236     * @throws \sql\MydbException\ConnectException
237     * @return ?array<string>
238     */
239    public function getPrimaryKeys(string $table): ?array
240    {
241        $result = $this->query($this->queryBuilder->showKeys($table));
242
243        if (null === $result) {
244            return null;
245        }
246
247        $keys = [];
248        foreach ($result as $row) {
249            if (isset($row['Key_name']) && 'PRIMARY' === $row['Key_name'] && isset($row['Column_name'])) {
250                $keys[] = (string) $row['Column_name'];
251            }
252        }
253
254        return [] !== $keys ? $keys : null;
255    }
256
257    /**
258     * @throws \sql\MydbException
259     */
260    public function beginTransaction(): void
261    {
262        if (!$this->connect()) {
263            throw new ConnectException();
264        }
265
266        if ($this->options->isReadonly()) {
267            if ($this->mysqli->beginTransactionReadonly()) {
268                return;
269            }
270            $this->onError(new TransactionBeginReadonlyException());
271        } else {
272            if ($this->mysqli->beginTransactionReadwrite()) {
273                return;
274            }
275            $this->onError(new TransactionBeginReadwriteException());
276        }
277    }
278
279    /**
280     * @throws \sql\MydbException
281     */
282    public function rollbackTransaction(): void
283    {
284        if (!$this->mysqli->isConnected()) {
285            throw new ConnectException();
286        }
287
288        if ($this->mysqli->rollback()) {
289            return;
290        }
291
292        $this->onError(new TransactionRollbackException());
293    }
294
295    /**
296     * @throws \sql\MydbException\ConnectException
297     * @throws \sql\MydbException
298     */
299    public function commitTransaction(): void
300    {
301        if (!$this->mysqli->isConnected()) {
302            throw new ConnectException();
303        }
304
305        if ($this->mysqli->commit()) {
306            return;
307        }
308
309        $this->onError(new TransactionCommitException());
310    }
311
312    /**
313     * @throws \sql\MydbException
314     */
315    public function close(): void
316    {
317        if (false === $this->mysqli->isConnected()) {
318            return;
319        }
320
321        try {
322            /**
323             * No autocommit
324             * No transaction
325             *
326             * Default: commit all commands if transaction was NOT open
327             */
328            if (false === $this->options->isAutocommit() && false === $this->mysqli->isTransactionOpen()) {
329                /**
330                 * RELEASE clause causes the server to disconnect the current client session
331                 * after terminating the current transaction.
332                 */
333                $commit = $this->options->isPersistent()
334                    ? $this->mysqli->commit()
335                    : $this->mysqli->commitAndRelease();
336
337                if (false === $commit) {
338                    throw new TransactionCommitException();
339                }
340            }
341
342            /**
343             * Explicitly closing open connections and freeing result sets is optional but recommended
344             * Server already closed connection from server-side
345             */
346            if (false === $this->mysqli->close()) {
347                throw new DisconnectException();
348            }
349        } catch (MydbException $e) {
350            $this->onError($e);
351        } catch (Throwable $e) {
352            $this->onError(new InternalException($e->getMessage()));
353        }
354
355        if ($this->terminating) {
356            return;
357        }
358
359        $this->environment->gc_collect_cycles();
360    }
361
362    /**
363     * @throws \sql\MydbException\ConnectException
364     * @throws \sql\MydbException
365     */
366    public function replace(string $query): ?string
367    {
368        return $this->insert($query);
369    }
370
371    /**
372     * @throws \sql\MydbException\ConnectException
373     * @throws \sql\MydbException
374     */
375    public function insert(string $query): ?string
376    {
377        if ($this->command($query)) {
378            return (string) $this->mysqli->getInsertId();
379        }
380
381        return null;
382    }
383
384    /**
385     * @phpcs:disable SlevomatCodingStandard.TypeHints.ReturnTypeHint
386     * @throws \sql\MydbException
387     * @throws \sql\MydbException\ConnectException
388     */
389    public function select(string $query): ?array
390    {
391        return $this->query($query);
392    }
393
394    /**
395     * @throws \sql\MydbException
396     */
397    public function delete(string $query): ?int
398    {
399        if ($this->command($query)) {
400            $rows = $this->mysqli->getAffectedRows();
401            if (null === $rows) {
402                $this->onError(new DeleteException(), $query);
403            }
404
405            return $rows;
406        }
407
408        return null;
409    }
410
411    /**
412     * @throws \sql\MydbException
413     */
414    public function update(string $query): ?int
415    {
416        if ($this->command($query)) {
417            $rows = $this->mysqli->getAffectedRows();
418            if (null === $rows) {
419                $this->onError(new UpdateException(), $query);
420            }
421
422            return $rows;
423        }
424
425        return null;
426    }
427
428    /**
429     * @throws \sql\MydbException
430     */
431    public function deleteWhere(array $whereFields, string $table, array $whereNotFields = []): ?int
432    {
433        $query = $this->queryBuilder->buildDeleteWhere($table, $whereFields, $whereNotFields);
434        if (null === $query) {
435            return null;
436        }
437
438        return $this->delete($query);
439    }
440
441    /**
442     * @param array<string, (float|int|string|\sql\MydbExpressionInterface|null)> $update
443     * @throws \sql\MydbException
444     */
445    public function updateWhere(array $update, array $whereFields, string $table, array $whereNotFields = []): ?int
446    {
447        $query = $this->queryBuilder->buildUpdateWhere($update, $whereFields, $table, $whereNotFields);
448
449        if ('' !== $query && null !== $query) {
450            return $this->update($query);
451        }
452
453        return null;
454    }
455
456    /**
457     * @param array $columnSetWhere ['col1' => [ ['current1', 'new1'], ['current2', 'new2']]
458     * @param array $where ['col2' => 'value2', 'col3' => ['v3', 'v4']]
459     * @param string $table 'mytable'
460     * @throws \sql\MydbException
461     */
462    public function updateWhereMany(array $columnSetWhere, array $where, string $table): void
463    {
464        $sql = $this->queryBuilder->buildUpdateWhereMany($columnSetWhere, $where, $table);
465        $this->update($sql);
466    }
467
468    /**
469     * @throws \sql\MydbException\ConnectException
470     * @throws \sql\MydbException
471     * @psalm-param array<array-key, array<array-key, (float|int|string|\sql\MydbExpressionInterface|null)>> $data
472     * @param array<string> $cols
473     */
474    public function insertMany(
475        array $data,
476        array $cols,
477        string $table,
478        bool $ignore = false,
479        string $onDuplicateSql = '',
480    ): void {
481        $sql = $this->queryBuilder->buildInsertMany($data, $cols, $table, $ignore, $onDuplicateSql);
482        $this->insert($sql);
483    }
484
485    /**
486     * @throws \sql\MydbException
487     * @param array<string, (float|int|\sql\MydbExpressionInterface|string|null)> $data
488     */
489    public function replaceOne(array $data, string $table): ?string
490    {
491        $query = $this->queryBuilder->insertOne($data, $table, MydbQueryBuilderInterface::SQL_REPLACE);
492
493        return $this->replace($query);
494    }
495
496    /**
497     * @throws \sql\MydbException
498     * @param array<string, (float|int|\sql\MydbExpressionInterface|string|null)> $data
499     */
500    public function insertOne(array $data, string $table): ?string
501    {
502        $query = $this->queryBuilder->insertOne($data, $table, MydbQueryBuilderInterface::SQL_INSERT);
503
504        return $this->insert($query);
505    }
506
507    /**
508     * @throws \sql\MydbException\EnvironmentException
509     * @throws \sql\MydbException\TerminationSignalException
510     * @throws \sql\MydbException\EventException
511     */
512    protected function sendClientRequest(string $query): bool
513    {
514        $this->environment->startSignalsTrap();
515        $this->environment->set_error_handler();
516
517        (new MydbEvent\InternalQueryBegin($query))->setListeners([$this->eventListener])->notify();
518        $result = $this->mysqli->realQuery($query);
519        (new MydbEvent\InternalQueryEnd($query, $result))->setListeners([$this->eventListener])->notify();
520
521        $this->environment->restore_error_handler();
522        $hasPendingSignals = $this->environment->endSignalsTrap();
523        if (null !== $hasPendingSignals && count($hasPendingSignals) > 0) {
524            throw new TerminationSignalException();
525        }
526
527        return $result;
528    }
529
530    /**
531     * @throws \sql\MydbException
532     */
533    protected function readServerResponse(string $query): ?MydbMysqliResultInterface
534    {
535        $packet = $this->mysqli->readServerResponse($this->environment);
536        if (null === $packet) {
537            return null;
538        }
539
540        $warnings = $packet->getWarnings();
541        if (count($warnings) > 0) {
542            foreach ($warnings as $warningMessage) {
543                $this->onWarning($warningMessage, $query);
544            }
545        }
546
547        $errorMessage = $packet->getError();
548        if (null !== $errorMessage) {
549            if ($this->mysqli->isServerGone()) {
550                /**
551                 * server closed connection, do close() and ignore result of operation
552                 */
553                $this->mysqli->close();
554                $this->onError(new ServerGoneException($errorMessage), $query);
555            } else {
556                $this->onError(new InternalException($errorMessage), $query);
557            }
558        }
559
560        return $packet;
561    }
562
563    protected function onWarning(string $warningMessage, ?string $sql = null): void
564    {
565        $this->logger->warning($warningMessage, ['sql' => $sql]);
566    }
567
568    /**
569     * @throws \sql\MydbException
570     */
571    protected function onError(MydbException $exception, ?string $sql = null): void
572    {
573        $this->logger->error($exception->getMessage(), ['sql' => $sql]);
574
575        throw $exception;
576    }
577
578    /**
579     * @throws \sql\MydbException\DisconnectException
580     * @throws \sql\MydbException\TransactionAutocommitException
581     * @throws \sql\MydbException\EnvironmentException
582     * @throws \sql\MydbException\EventException
583     * @throws \sql\MydbException
584     * @SuppressWarnings(PHPMD.NPathComplexity)
585     * @todo reduce NPathComplexity
586     */
587    protected function connect(int $retry = 0): bool
588    {
589        if ($this->mysqli->isConnected()) {
590            return true;
591        }
592
593        $connected = false;
594        $init0 = $this->mysqli->init();
595        $init1 = $init0 && $this->mysqli->setTransportOptions($this->options, $this->environment);
596
597        if ($init0 && $init1) {
598            $reportingLevel = $this->environment->error_reporting($this->options->getErrorReporting());
599
600            $host = ($this->options->isPersistent() ? 'p:' : '') . $this->credentials->getHost();
601            $dbname = $this->credentials->getDbname();
602
603            (new MydbEvent\InternalConnectionBegin($host, $dbname))
604                ->setListeners([$this->eventListener])->notify();
605            $connected = $this->mysqli->realConnect(
606                $host,
607                $this->credentials->getUsername(),
608                $this->credentials->getPasswd(),
609                $dbname,
610                $this->credentials->getPort(),
611                $this->credentials->getSocket(),
612                $this->credentials->getFlags()
613            );
614            (new MydbEvent\InternalConnectionEnd($host, $dbname, $connected))
615                ->setListeners([$this->eventListener])->notify();
616
617            $this->environment->error_reporting($reportingLevel);
618        }
619
620        if (false === $connected) {
621            $errorNumber = (string) ($this->mysqli->getConnectErrno() ?: $this->mysqli->getErrNo());
622            $errorText = (string) ($this->mysqli->getConnectError() ?: $this->mysqli->getError());
623
624            if (false === $this->mysqli->close()) {
625                throw new DisconnectException();
626            }
627
628            $this->onWarning($errorNumber . ' ' . $errorText);
629
630            if ($retry > 0) {
631                --$retry;
632
633                return $this->connect($retry);
634            }
635
636            return false;
637        }
638
639        $this->mysqli->mysqliReport($this->options->getClientErrorLevel());
640
641        if (false === $this->mysqli->autocommit($this->options->isAutocommit())) {
642            throw new TransactionAutocommitException();
643        }
644
645        /**
646         * Here's a summary of what variables are set by each statement: SET NAMES vs SET CHARSET
647         *
648         * Variable                 SET NAMES   SET CHARSET
649         * character_set_client     argument    argument
650         * character_set_results    argument    argument
651         * character_set_connection argument    default for default db
652         * collation_connection     argument*   default for default db
653         *
654         * SET CHARSET doesn't set the connection charset to the charset you specify in the argument,
655         * instead it sets it to the charset for your current default database
656         */
657        $defaults = $this->mysqli->realQuery(
658            sprintf(
659                "SET time_zone = '%s', wait_timeout = %d, names '%s'",
660                $this->options->getTimeZone(),
661                $this->options->getNonInteractiveTimeout(),
662                $this->options->getCharset()
663            )
664        );
665
666        if (false === $defaults) {
667            throw new ConnectDefaultsException();
668        }
669
670        $isoLevel = $this->options->getTransactionIsolationLevel();
671        if (null !== $isoLevel && false === $this->mysqli->setTransactionIsolationLevel($isoLevel)) {
672            throw new TransactionIsolationException();
673        }
674
675        if ($this->options->isReadonly() && false === $this->mysqli->beginTransactionReadonly()) {
676            throw new TransactionAutocommitException();
677        }
678
679        return true;
680    }
681}