Passed
Push — master ( 33c45f...ca5a76 )
by Marwan
10:38
created

Database.php$1 ➔ mock()   B

Complexity

Conditions 1

Size

Total Lines 66

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
c 0
b 0
f 0
dl 0
loc 66
ccs 0
cts 0
cp 0
crap 2
rs 8.7418

16 Methods

Rating   Name   Duplication   Size   Complexity  
A Database.php$1 ➔ errorCode() 0 3 1
A Database.php$1 ➔ fail() 0 5 1
A Database.php$1 ➔ getAttribute() 0 3 1
A Database.php$1 ➔ inTransaction() 0 3 1
A Database.php$1 ➔ lastInsertId() 0 3 1
A Database.php$1 ➔ rollBack() 0 3 1
A Database.php$1 ➔ quote() 0 3 1
A Database.php$1 ➔ errorInfo() 0 3 1
A Database.php$1 ➔ prepare() 0 3 1
A Database.php$1 ➔ exec() 0 3 1
A Database.php$1 ➔ query() 0 3 1
A Database.php$1 ➔ getAvailableDrivers() 0 3 1
A Database.php$1 ➔ setAttribute() 0 3 1
A Database.php$1 ➔ beginTransaction() 0 3 1
A Database.php$1 ➔ commit() 0 3 1
A Database.php$1 ➔ __construct() 0 2 1

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 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, ?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);
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
     * @codeCoverageIgnore This method is overridden (mocked) in the unit tests.
96
     */
97
    public static function instance(): Database
98
    {
99
        $databaseConfig = Config::get('database', []);
100
101
        try {
102
            return static::connect(
103
                $databaseConfig['dsn'] ?? '',
104
                $databaseConfig['username'] ?? null,
105
                $databaseConfig['password'] ?? null,
106
                $databaseConfig['options'] ?? null
107
            );
108
        } catch (\PDOException $error) {
109
            // connection can't be established (incorrect config), return a fake instance
110
            return static::mock();
111
        }
112
    }
113
114
    /**
115
     * Returns FQN for a custom PDOStatement class.
116
     *
117
     * @return string
118
     */
119 24
    private function getStatementClass(): string
120
    {
121 2
        $statement = new class () extends \PDOStatement {
122
            // Makes method chaining a little bit more convenient.
123
            public function execute($params = null)
124
            {
125 24
                parent::execute($params);
126
127 24
                return $this;
128
            }
129
            // Catches the debug dump instead of printing it out directly.
130
            public function debugDumpParams()
131
            {
132 1
                ob_start();
133
134 1
                parent::debugDumpParams();
135
136 1
                $dump = ob_get_contents();
137 1
                ob_end_clean();
138
139 1
                return $dump;
140
            }
141
        };
142
143 2
        return get_class($statement);
144
    }
145
146
    /**
147
     * Adds caching capabilities for prepared statement.
148
     * {@inheritDoc}
149
     */
150 24
    public function prepare($query, $options = [])
151
    {
152 24
        $hash = md5($query);
153
154 24
        if (!isset($this->cache[$hash])) {
155 11
            $this->cache[$hash] = parent::prepare($query, $options);
156
        }
157
158 24
        return $this->cache[$hash];
159
    }
160
161
    /**
162
     * A wrapper method to perform a query on the fly using either `self::query()` or `self::prepare()` + `self::execute()`.
163
     *
164
     * @param string $query The query to execute.
165
     * @param array $params The parameters to bind to the query.
166
     *
167
     * @return \PDOStatement
168
     */
169 35
    public function perform(string $query, ?array $params = null): \PDOStatement
170
    {
171
        try {
172 35
            if (empty($params)) {
173 34
                return $this->query($query);
174
            }
175
176 24
            $statement = $this->prepare($query);
177 24
            $statement->execute($params);
178
179 24
            return $statement;
180 2
        } catch (\PDOException $error) {
181 2
            throw $error;
182
        }
183
    }
184
185
    /**
186
     * Serves as a wrapper method to execute some operations in transactional context with the ability to attempt retires.
187
     *
188
     * @param callable $callback The callback to execute inside the transaction. This callback will be bound to the `Database` class.
189
     * @param int $retries The number of times to attempt the transaction. Each retry will be delayed by 1-3 seconds.
190
     *
191
     * @return mixed The result of the callback.
192
     */
193 25
    public function transactional(callable $callback, int $retries = 3)
194
    {
195 25
        $callback = \Closure::fromCallable($callback)->bindTo($this);
196 25
        $attempts = 0;
197 25
        $return   = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $return is dead and can be removed.
Loading history...
198
199
        do {
200 25
            $this->beginTransaction();
201
202
            try {
203 25
                $return = $callback($this);
204
205 24
                $this->commit();
206
207 24
                break;
208 1
            } catch (\Throwable $error) {
209 1
                $this->rollBack();
210
211 1
                if (++$attempts === $retries) {
212 1
                    throw new \Exception(
213 1
                        "Could not complete the transaction after {$retries} attempt(s).",
214 1
                        (int)$error->getCode(),
215
                        $error
216
                    );
217
                }
218
219 1
                sleep(rand(1, 3));
220 1
            } finally {
221 25
                if ($this->inTransaction()) {
222 25
                    $this->rollBack();
223
                }
224
            }
225 1
        } while ($attempts < $retries);
226
227 24
        return $return;
228
    }
229
230
    /**
231
     * Returns a fake instance of the `Database` class.
232
     *
233
     * @return Database This instance will throw an exception if a method is called.
234
     *
235
     * @codeCoverageIgnore
236
     */
237
    private static function mock()
238
    {
239
        return new class () extends Database {
240
            protected function __construct()
241
            {}
242
            public static function getAvailableDrivers()
243
            {
244
                static::fail();
245
            }
246
            public function getAttribute($attribute)
247
            {
248
                static::fail();
249
            }
250
            public function setAttribute($attribute, $value)
251
            {
252
                static::fail();
253
            }
254
            public function exec($statement)
255
            {
256
                static::fail();
257
            }
258
            public function prepare($query, $options = [])
259
            {
260
                static::fail();
261
            }
262
            public function query($query, $fetchMode = null, ...$fetchModeArgs)
263
            {
264
                static::fail();
265
            }
266
            public function quote($string, $type = \PDO::PARAM_STR)
267
            {
268
                static::fail();
269
            }
270
            public function lastInsertId($name = null)
271
            {
272
                static::fail();
273
            }
274
            public function beginTransaction()
275
            {
276
                static::fail();
277
            }
278
            public function inTransaction()
279
            {
280
                static::fail();
281
            }
282
            public function commit()
283
            {
284
                static::fail();
285
            }
286
            public function rollBack()
287
            {
288
                static::fail();
289
            }
290
            public function errorCode()
291
            {
292
                static::fail();
293
            }
294
            public function errorInfo()
295
            {
296
                static::fail();
297
            }
298
            private static function fail(): void
299
            {
300
                throw new \Exception(
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