Database::transactional()
last analyzed

Size

Total Lines 36
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 23
c 4
b 0
f 0
nc 29
nop 2
dl 0
loc 36
ccs 18
cts 18
cp 1
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
use MAKS\Velox\Backend\Exception;
15
16
/**
17
 * A class that represents the database and handles database operations.
18
 *
19
 * Example:
20
 * ```
21
 * $database = Database::instance();
22
 * $database->query('SELECT * FROM `users`');
23
 * $database->prepare('SELECT * FROM `users` WHERE `job` = :job LIMIT 5')->execute([':job' => 'Developer'])->fetchAll();
24
 * $database->perform('SELECT * FROM `users` WHERE `title` LIKE :title AND `id` > :id', ['title' => 'Dr.%', 'id' => 1])->fetchAll();
25
 * ```
26
 *
27
 * @package Velox\Backend
28
 * @since 1.3.0
29
 * @api
30
 */
31
class Database extends \PDO
32
{
33
    /**
34
     * Current open database connections.
35
     */
36
    protected static array $connections;
37
38
    /**
39
     * A cache to hold prepared statements.
40
     */
41
    protected array $cache;
42
43
    protected string $dsn;
44
    protected ?string $username;
45
    protected ?string $password;
46
    protected ?array $options;
47
48
49
    /**
50
     * Class constructor.
51
     *
52
     * Adds some default options to the PDO connection.
53
     *
54
     * @param string $dsn
55
     * @param string|null $username
56
     * @param string|null $password
57
     * @param array|null $options
58
     */
59 3
    protected function __construct(string $dsn, ?string $username = null, ?string $password = null, ?array $options = null)
60
    {
61 3
        $this->dsn      = $dsn;
62 3
        $this->username = $username;
63 3
        $this->password = $password;
64 3
        $this->options  = $options;
65
66 3
        $this->cache = [];
67
68 3
        parent::__construct($dsn, $username, $password, $options);
69
70 3
        $this->setAttribute(static::ATTR_ERRMODE, static::ERRMODE_EXCEPTION);
71 3
        $this->setAttribute(static::ATTR_DEFAULT_FETCH_MODE, static::FETCH_ASSOC);
72 3
        $this->setAttribute(static::ATTR_EMULATE_PREPARES, false);
73 3
        $this->setAttribute(static::MYSQL_ATTR_FOUND_ROWS, true);
74 3
        $this->setAttribute(static::ATTR_STATEMENT_CLASS, [$this->getStatementClass()]);
75
    }
76
77
78
    /**
79
     * Returns a singleton instance of the `Database` class based on connection credentials.
80
     * This method makes sure that a single connection is opened and reused for each connection credentials set (DSN, User, Password, ...).
81
     *
82
     * @param string|null $dsn The DSN string.
83
     * @param string|null $username [optional] The database username.
84
     * @param string|null $password [optional] The database password.
85
     * @param array|null $options [optional] PDO options.
86
     *
87
     * @return static
88
     */
89 55
    final public static function connect(string $dsn, ?string $username = null, ?string $password = null, ?array $options = null): Database
90
    {
91 55
        $connection = md5(serialize(func_get_args()));
92
93 55
        if (!isset(static::$connections[$connection])) {
94 3
            static::$connections[$connection] = new static($dsn, $username, $password, $options);
95
        }
96
97 55
        return static::$connections[$connection];
98
    }
99
100
    /**
101
     * Returns the singleton instance of the `Database` class using credentials found in `{database}` config.
102
     *
103
     * @return static
104
     *
105
     * @codeCoverageIgnore This method is overridden (mocked) in tests.
106
     */
107
    public static function instance(): Database
108
    {
109
        $databaseConfig = Config::get('database', []);
110
111
        try {
112
            return static::connect(
113
                $databaseConfig['dsn'] ?? '',
114
                $databaseConfig['username'] ?? null,
115
                $databaseConfig['password'] ?? null,
116
                $databaseConfig['options'] ?? null
117
            );
118
        } catch (\PDOException $error) {
119
            // connection can't be established (incorrect config), return a fake instance
120
            return static::mock();
121
        }
122
    }
123
124
    /**
125
     * Returns FQN for a custom `PDOStatement` class.
126
     *
127
     * @return string
128
     */
129 31
    private function getStatementClass(): string
130
    {
131 3
        $statement = new class () extends \PDOStatement {
132
            // Makes method chaining a little bit more convenient.
133
            #[\ReturnTypeWillChange]
134
            public function execute($params = null)
135 31
            {
136
                parent::execute($params);
137 31
138
                return $this;
139
            }
140
            // Catches the debug dump instead of printing it out directly.
141
            #[\ReturnTypeWillChange]
142 1
            public function debugDumpParams()
143
            {
144 1
                ob_start();
145
146 1
                parent::debugDumpParams();
147 1
148
                $dump = ob_get_contents();
149 1
                ob_end_clean();
150
151
                return $dump;
152
            }
153 3
        };
154
155
        return get_class($statement);
156
    }
157
158
    /**
159
     * Adds caching capabilities for prepared statement.
160 31
     * {@inheritDoc}
161
     */
162 31
    #[\ReturnTypeWillChange]
163
    public function prepare($query, $options = [])
164 31
    {
165 14
        $hash = md5($query);
166
167
        if (!isset($this->cache[$hash])) {
168 31
            $this->cache[$hash] = parent::prepare($query, $options);
169
        }
170
171
        return $this->cache[$hash];
172
    }
173
174
    /**
175
     * A wrapper method to perform a query on the fly using either `self::query()` or `self::prepare()` + `self::execute()`.
176
     *
177
     * @param string $query The query to execute.
178
     * @param array $params The parameters to bind to the query.
179 45
     *
180
     * @return \PDOStatement
181
     */
182 45
    public function perform(string $query, ?array $params = null): \PDOStatement
183 44
    {
184
        try {
185
            if (empty($params)) {
186 31
                return $this->query($query);
187 31
            }
188
189 31
            $statement = $this->prepare($query);
190 2
            $statement->execute($params);
191 2
192
            return $statement;
193 2
        } catch (\PDOException $error) {
194 2
            Exception::throw(
195
                'QueryFailedException:PDOException',
196
                "Could not execute the query '{$query}'",
197
                (int)$error->getCode(),
198
                $error
199
            );
200
        }
201
    }
202
203
    /**
204
     * Serves as a wrapper method to execute some operations in transactional context with the ability to attempt retires.
205
     *
206
     * @param callable $callback The callback to execute inside the transaction. This callback will be bound to the `Database` class.
207
     * @param int $retries The number of times to attempt the transaction. Each retry will be delayed by 1-3 seconds.
208
     *
209
     * @return mixed The result of the callback.
210 32
     *
211
     * @throws \RuntimeException If the transaction fails after all retries.
212 32
     */
213 32
    public function transactional(callable $callback, int $retries = 3)
214 32
    {
215
        $callback = \Closure::fromCallable($callback)->bindTo($this);
216
        $attempts = 0;
217 32
        $return   = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $return is dead and can be removed.
Loading history...
218
219
        do {
220 32
            $this->beginTransaction();
221
222 31
            try {
223
                $return = $callback($this);
224 31
225 1
                $this->commit();
226 1
227
                break;
228 1
            } catch (\Throwable $error) {
229 1
                $this->rollBack();
230
231 1
                if (++$attempts === $retries) {
232 1
                    Exception::throw(
233
                        'TransactionFailedException:RuntimeException',
234
                        "Could not complete the transaction after {$retries} attempt(s).",
235
                        (int)$error->getCode(),
236
                        $error
237 1
                    );
238 1
                }
239 32
240 32
                sleep(rand(1, 3));
241
            } finally {
242
                if ($this->inTransaction()) {
243 1
                    $this->rollBack();
244
                }
245 31
            }
246
        } while ($attempts < $retries);
247
248
        return $return;
249
    }
250
251
    /**
252
     * Returns a fake instance of the `Database` class.
253
     *
254
     * @return Database This instance will throw an exception if a method is called.
255
     *
256
     * @codeCoverageIgnore
257
     */
258
    private static function mock()
259
    {
260
        return new class () extends Database {
261
            // only methods that raise an error or throw an exception are overridden
262
            protected function __construct()
263
            {
264
                // constructor arguments are not used
265
            }
266
            #[\ReturnTypeWillChange]
267
            public function exec($statement)
268
            {
269
                static::fail();
270
            }
271
            #[\ReturnTypeWillChange]
272
            public function prepare($query, $options = [])
273
            {
274
                static::fail();
275
            }
276
            #[\ReturnTypeWillChange]
277
            public function query($query, $fetchMode = null, ...$fetchModeArgs)
278
            {
279
                static::fail();
280
            }
281
            #[\ReturnTypeWillChange]
282
            public function beginTransaction()
283
            {
284
                static::fail();
285
            }
286
            #[\ReturnTypeWillChange]
287
            public function commit()
288
            {
289
                static::fail();
290
            }
291
            #[\ReturnTypeWillChange]
292
            public function rollBack()
293
            {
294
                static::fail();
295
            }
296
297
            private static function fail(): void
298
            {
299
                Exception::throw(
300
                    'ConnectionFailedException:LogicException',
301
                    'The app is currently running using a fake database, all database related operations will fail. ' .
302
                    'Add valid database credentials using "config/database.php" to resolve this issue'
303
                );
304
            }
305
        };
306
    }
307
}
308