Passed
Push — master ( 766bf6...808da0 )
by Marwan
07:32
created

Database.php$1 ➔ commit()   A

Complexity

Conditions 1

Size

Total Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
c 2
b 0
f 0
dl 0
loc 3
ccs 0
cts 0
cp 0
crap 2
rs 10
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
            public function execute($params = null)
134
            {
135 31
                parent::execute($params);
136
137 31
                return $this;
138
            }
139
            // Catches the debug dump instead of printing it out directly.
140
            public function debugDumpParams()
141
            {
142 1
                ob_start();
143
144 1
                parent::debugDumpParams();
145
146 1
                $dump = ob_get_contents();
147 1
                ob_end_clean();
148
149 1
                return $dump;
150
            }
151
        };
152
153 3
        return get_class($statement);
154
    }
155
156
    /**
157
     * Adds caching capabilities for prepared statement.
158
     * {@inheritDoc}
159
     */
160 31
    public function prepare($query, $options = [])
161
    {
162 31
        $hash = md5($query);
163
164 31
        if (!isset($this->cache[$hash])) {
165 14
            $this->cache[$hash] = parent::prepare($query, $options);
166
        }
167
168 31
        return $this->cache[$hash];
169
    }
170
171
    /**
172
     * A wrapper method to perform a query on the fly using either `self::query()` or `self::prepare()` + `self::execute()`.
173
     *
174
     * @param string $query The query to execute.
175
     * @param array $params The parameters to bind to the query.
176
     *
177
     * @return \PDOStatement
178
     */
179 45
    public function perform(string $query, ?array $params = null): \PDOStatement
180
    {
181
        try {
182 45
            if (empty($params)) {
183 44
                return $this->query($query);
184
            }
185
186 31
            $statement = $this->prepare($query);
187 31
            $statement->execute($params);
188
189 31
            return $statement;
190 2
        } catch (\PDOException $error) {
191 2
            Exception::throw(
192
                'QueryFailedException:PDOException',
193 2
                "Could not execute the query '{$query}'",
194 2
                (int)$error->getCode(),
195
                $error
196
            );
197
        }
198
    }
199
200
    /**
201
     * Serves as a wrapper method to execute some operations in transactional context with the ability to attempt retires.
202
     *
203
     * @param callable $callback The callback to execute inside the transaction. This callback will be bound to the `Database` class.
204
     * @param int $retries The number of times to attempt the transaction. Each retry will be delayed by 1-3 seconds.
205
     *
206
     * @return mixed The result of the callback.
207
     *
208
     * @throws \RuntimeException If the transaction fails after all retries.
209
     */
210 32
    public function transactional(callable $callback, int $retries = 3)
211
    {
212 32
        $callback = \Closure::fromCallable($callback)->bindTo($this);
213 32
        $attempts = 0;
214 32
        $return   = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $return is dead and can be removed.
Loading history...
215
216
        do {
217 32
            $this->beginTransaction();
218
219
            try {
220 32
                $return = $callback($this);
221
222 31
                $this->commit();
223
224 31
                break;
225 1
            } catch (\Throwable $error) {
226 1
                $this->rollBack();
227
228 1
                if (++$attempts === $retries) {
229 1
                    Exception::throw(
230
                        'TransactionFailedException:RuntimeException',
231 1
                        "Could not complete the transaction after {$retries} attempt(s).",
232 1
                        (int)$error->getCode(),
233
                        $error
234
                    );
235
                }
236
237 1
                sleep(rand(1, 3));
238 1
            } finally {
239 32
                if ($this->inTransaction()) {
240 32
                    $this->rollBack();
241
                }
242
            }
243 1
        } while ($attempts < $retries);
244
245 31
        return $return;
246
    }
247
248
    /**
249
     * Returns a fake instance of the `Database` class.
250
     *
251
     * @return Database This instance will throw an exception if a method is called.
252
     *
253
     * @codeCoverageIgnore
254
     */
255
    private static function mock()
256
    {
257
        return new class () extends Database {
258
            // only methods that raise an error or throw an exception are overridden
259
            protected function __construct()
260
            {
261
                // constructor arguments are not used
262
            }
263
            public function exec($statement)
264
            {
265
                static::fail();
266
            }
267
            public function prepare($query, $options = [])
268
            {
269
                static::fail();
270
            }
271
            public function query($query, $fetchMode = null, ...$fetchModeArgs)
272
            {
273
                static::fail();
274
            }
275
            public function beginTransaction()
276
            {
277
                static::fail();
278
            }
279
            public function commit()
280
            {
281
                static::fail();
282
            }
283
            public function rollBack()
284
            {
285
                static::fail();
286
            }
287
            private static function fail(): void
288
            {
289
                Exception::throw(
290
                    'ConnectionFailedException:LogicException',
291
                    'The app is currently running using a fake database, all database related operations will fail. ' .
292
                    'Add valid database credentials using "config/database.php" to resolve this issue'
293
                );
294
            }
295
        };
296
    }
297
}
298