Passed
Push — master ( 48e2e4...58f05f )
by Marwan
08:52
created

Database.php$1 ➔ mock()   A

Complexity

Conditions 1

Size

Total Lines 37

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 37
ccs 0
cts 0
cp 0
crap 2
rs 9.328

8 Methods

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