Passed
Push — master ( 6c6d7e...b48c4f )
by Marwan
01:39
created

anonymous//classes/Backend/Database.php$0   A

Complexity

Total Complexity 2

Size/Duplication

Total Lines 19
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 19
ccs 8
cts 8
cp 1
rs 10
wmc 2
1
<?php
2
3
/**
4
 * @author Marwan Al-Soltany <[email protected]>
5
 * @copyright Marwan Al-Soltany 2021
6
 * For the full copyright and license information, please view
7
 * the LICENSE file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace MAKS\Velox\Backend;
13
14
/**
15
 * A class that represents the database and handles database operations.
16
 *
17
 * Example:
18
 * ```
19
 * $database = Database::instance();
20
 * $database->query('SELECT * FROM `users`');
21
 * $database->prepare('SELECT * FROM `users` WHERE `job` = :job LIMIT 5')->execute([':job' => 'Developer'])->fetchAll();
22
 * $database->perform('SELECT * FROM `users` WHERE `title` LIKE :title AND `id` > :id', ['title' => 'Dr.%', 'id' => 1])->fetchAll();
23
 * ```
24
 *
25
 * @since 1.3.0
26
 * @api
27
 */
28
class Database extends \PDO
29
{
30
    /**
31
     * Current open database connections.
32
     */
33
    protected static array $connections;
34
35
    /**
36
     * A cache to hold prepared statements.
37
     */
38
    protected array $cache;
39
40
    protected string $dsn;
41
    protected string $username;
42
    protected string $password;
43
    protected array $options;
44
45
46
    /**
47
     * Adds some default options to the PDO connection.
48
     */
49 2
    protected function __construct(string $dsn, ?string $username = null, ?string $password = null, ?array $options = null)
50
    {
51 2
        $this->dsn      = $dsn;
52 2
        $this->username = $username;
53 2
        $this->password = $password;
54 2
        $this->options  = $options ?? [];
55
56 2
        $this->cache = [];
57
58 2
        parent::__construct($dsn, $username, $password, $options ?? []);
59
60 2
        $this->setAttribute(static::ATTR_ERRMODE, static::ERRMODE_EXCEPTION);
61 2
        $this->setAttribute(static::ATTR_DEFAULT_FETCH_MODE, static::FETCH_ASSOC);
62 2
        $this->setAttribute(static::ATTR_EMULATE_PREPARES, FALSE);
63 2
        $this->setAttribute(static::MYSQL_ATTR_FOUND_ROWS, TRUE);
64 2
        $this->setAttribute(static::ATTR_STATEMENT_CLASS, [$this->getStatementClass()]);
65 2
    }
66
67
68
    /**
69
     * Returns a singleton instance of the `Database` class based on connection credentials.
70
     * This method makes sure that a single connection is opened and reused for each connection credentials set (DSN, User, Password, ...).
71
     *
72
     * @param string|null $dsn [optional] The DSN string. If not specified, it will be retrieved from the config.
73
     * @param string|null $username [optional] The database username. If not specified, it will be retrieved from the config.
74
     * @param string|null $password [optional] The database password. If not specified, it will be retrieved from the config.
75
     * @param array|null $options [optional] PDO options. If not specified, it will be retrieved from the config.
76
     *
77
     * @return static
78
     */
79 45
    final public static function connect(?string $dsn = null, ?string $username = null, ?string $password = null, ?array $options = null): Database
80
    {
81 45
        $connection = md5(serialize(func_get_args()));
82
83 45
        if (!isset(static::$connections[$connection])) {
84 2
            static::$connections[$connection] = new static($dsn, $username, $password, $options);
0 ignored issues
show
Bug introduced by
It seems like $dsn can also be of type null; however, parameter $dsn of MAKS\Velox\Backend\Database::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

84
            static::$connections[$connection] = new static(/** @scrutinizer ignore-type */ $dsn, $username, $password, $options);
Loading history...
85
        }
86
87 45
        return static::$connections[$connection];
88
    }
89
90
    /**
91
     * Returns the singleton instance of the `Database` class using credentials found in "{database}" config.
92
     *
93
     * @return static
94
     */
95 12
    public static function instance(): Database
96
    {
97 12
        $databaseConfig = Config::get('database', []);
98
99 12
        return static::connect(
100 12
            $databaseConfig['dsn'] ?? null,
101 12
            $databaseConfig['username'] ?? null,
102 12
            $databaseConfig['password'] ?? null,
103 12
            $databaseConfig['options'] ?? null
104
        );
105
    }
106
107
    /**
108
     * Returns FQN for a custom PDOStatement class.
109
     *
110
     * @return string
111
     */
112 24
    private function getStatementClass(): string
113
    {
114 2
        $statement = new class () extends \PDOStatement {
115
            // Makes method chaining a little bit more convenient.
116
            public function execute($params = null)
117
            {
118 24
                parent::execute($params);
119
120 24
                return $this;
121
            }
122
            // Catches the debug dump instead of printing it out directly.
123
            public function debugDumpParams()
124
            {
125 1
                ob_start();
126
127 1
                parent::debugDumpParams();
128
129 1
                $dump = ob_get_contents();
130 1
                ob_end_clean();
131
132 1
                return $dump;
133
            }
134
        };
135
136 2
        return get_class($statement);
137
    }
138
139
    /**
140
     * Adds caching capabilities for prepared statement.
141
     * {@inheritDoc}
142
     */
143 24
    public function prepare($query, $options = null)
144
    {
145 24
        $hash = md5($query);
146
147 24
        if (!isset($this->cache[$hash])) {
148 11
            $this->cache[$hash] = parent::prepare($query, $options ?? []);
149
        }
150
151 24
        return $this->cache[$hash];
152
    }
153
154
    /**
155
     * A wrapper method to perform a query on the fly using either `self::query()` or `self::prepare()` + `self::execute()`.
156
     *
157
     * @param string $query The query to execute.
158
     * @param array $params The parameters to bind to the query.
159
     *
160
     * @return \PDOStatement
161
     */
162 35
    public function perform(string $query, ?array $params = null): \PDOStatement
163
    {
164
        try {
165 35
            if (empty($params)) {
166 34
                return $this->query($query);
167
            }
168
169 24
            $statement = $this->prepare($query);
170 24
            $statement->execute($params);
171
172 24
            return $statement;
173 2
        } catch (\PDOException $error) {
174 2
            throw $error;
175
        }
176
    }
177
178
    /**
179
     * Serves as a wrapper method to execute some operations in transactional context with the ability to attempt retires.
180
     *
181
     * @param callable $callback The callback to execute inside the transaction. This callback will be bound to the `Database` class.
182
     * @param int $retries The number of times to attempt the transaction.
183
     *
184
     * @return mixed The result of the callback.
185
     */
186 25
    public function transactional(callable $callback, int $retries = 3)
187
    {
188 25
        $callback = \Closure::fromCallable($callback)->bindTo($this);
189 25
        $attempts = 0;
190 25
        $return   = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $return is dead and can be removed.
Loading history...
191
192
        do {
193 25
            $this->beginTransaction();
194
195
            try {
196 25
                $return = $callback($this);
197
198 24
                $this->commit();
199
200 24
                break;
201 1
            } catch(\Throwable $error) {
202 1
                $this->rollBack();
203
204 1
                if (++$attempts === $retries) {
205 1
                    throw new \Exception(
206 1
                        "Could not complete the transaction after {$retries} attempt(s).", 0, $error
207
                    );
208
                }
209 1
            } finally {
210 25
                if ($this->inTransaction()) {
211 25
                    $this->rollBack();
212
                }
213
            }
214 1
        } while ($attempts < $retries);
215
216 24
        return $return;
217
    }
218
}
219