Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
121 / 121
100.00% covered (success)
100.00%
99 / 99
58.82% covered (warning)
58.82%
60 / 102
100.00% covered (success)
100.00%
29 / 29
CRAP
100.00% covered (success)
100.00%
1 / 1
Database
100.00% covered (success)
100.00%
121 / 121
100.00% covered (success)
100.00%
99 / 99
58.82% covered (warning)
58.82%
60 / 102
100.00% covered (success)
100.00%
29 / 29
340.09
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __get
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
8 / 8
37.50% covered (danger)
37.50%
3 / 8
100.00% covered (success)
100.00%
1 / 1
7.91
 getFetchMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 connected
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPdoDriver
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSqlDirs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadScript
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
12 / 12
60.00% covered (warning)
60.00%
6 / 10
100.00% covered (success)
100.00%
1 / 1
8.30
 placeholderCompiler
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 compilePlaceholders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 connect
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 disconnect
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
6 / 6
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
4.25
 reconnect
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ping
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 reset
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
7 / 7
80.00% covered (warning)
80.00%
4 / 5
100.00% covered (success)
100.00%
1 / 1
4.13
 checkAfterFailure
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 rollBackOpenTransaction
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 markSuspect
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 quote
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 begin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 commit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rollback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 transactionCall
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getConn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requirePdo
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 checkReuse
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
15 / 15
21.05% covered (danger)
21.05%
8 / 38
100.00% covered (success)
100.00%
1 / 1
39.49
 drop
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markConnected
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 touchConnection
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
3 / 3
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
2.50
 execute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Quma;
6
7use Celema\Quma\Exception\ConnectionFailed;
8use Closure;
9use PDO;
10use PDOException;
11use RuntimeException;
12use Throwable;
13
14/**
15 * @api
16 * @psalm-no-seal-properties
17 */
18class Database
19{
20    public readonly bool $debug;
21    protected ?PDO $pdo = null;
22    protected ?int $connectedAt = null;
23    protected ?int $lastUsedAt = null;
24    /** Whether an operation failed on the connection since the last reset. */
25    protected bool $suspect = false;
26    /** @var array<string, LoadedScript> */
27    protected array $compiledScripts = [];
28
29    public function __construct(
30        protected readonly Connection $conn,
31    ) {
32        $this->debug = Debug::enabled();
33    }
34
35    public function __get(string $key): Folder
36    {
37        Util::assertPathSegment($key, 'SQL folder name');
38
39        $exists = false;
40
41        foreach ($this->conn->config->sql as $path) {
42            $exists = is_dir($path . DIRECTORY_SEPARATOR . $key);
43
44            if ($exists) {
45                break;
46            }
47        }
48
49        if (!$exists) {
50            throw new RuntimeException('The SQL folder does not exist: ' . $key);
51        }
52
53        return new Folder($this, $key);
54    }
55
56    public function getFetchMode(): int
57    {
58        return $this->conn->config->pdo->fetchMode;
59    }
60
61    public function connected(): bool
62    {
63        return $this->pdo !== null;
64    }
65
66    public function getPdoDriver(): string
67    {
68        return $this->conn->config->driver;
69    }
70
71    /** @return list<non-empty-string> */
72    public function getSqlDirs(): array
73    {
74        return $this->conn->config->sql;
75    }
76
77    public function loadScript(string $path, bool $isTemplate): LoadedScript
78    {
79        $key = ($isTemplate ? 'tpql:' : 'sql:') . $path;
80
81        if (array_key_exists($key, $this->compiledScripts)) {
82            return $this->compiledScripts[$key];
83        }
84
85        if ($isTemplate) {
86            if (!is_readable($path)) {
87                throw new RuntimeException('Could not read SQL script: ' . $path);
88            }
89
90            $script = new LoadedScript($path, $path, compile: $this->placeholderCompiler());
91            $this->compiledScripts[$key] = $script;
92
93            return $script;
94        }
95
96        $source = file_get_contents($path);
97
98        if ($source === false) {
99            throw new RuntimeException('Could not read SQL script: ' . $path);
100        }
101
102        $compiled = $this->compilePlaceholders($source, $path);
103        $script = new LoadedScript($compiled, $path);
104        $this->compiledScripts[$key] = $script;
105
106        return $script;
107    }
108
109    /** @return Closure(string, string): string */
110    private function placeholderCompiler(): Closure
111    {
112        return $this->compilePlaceholders(...);
113    }
114
115    private function compilePlaceholders(string $source, string $path): string
116    {
117        return $this->conn->config->placeholders?->compileSql($source, $path) ?? $source;
118    }
119
120    public function connect(): static
121    {
122        if ($this->pdo !== null) {
123            return $this;
124        }
125
126        $config = $this->conn->config;
127
128        try {
129            $pdo = new PDO(
130                $config->dsn,
131                $config->pdo->username,
132                $config->pdo->password,
133                $config->pdo->effectiveOptions($config->driver),
134            );
135        } catch (PDOException $e) {
136            throw ConnectionFailed::from($e);
137        }
138
139        $this->pdo = $pdo;
140        $this->markConnected();
141
142        return $this;
143    }
144
145    public function disconnect(): void
146    {
147        if ($this->pdo !== null) {
148            try {
149                if ($this->pdo->inTransaction()) {
150                    $this->pdo->rollBack();
151                }
152            } catch (Throwable) {
153                // @mago-expect lint:no-empty-catch-clause Rollback failures are intentionally ignored during teardown.
154            }
155        }
156
157        $this->drop();
158    }
159
160    public function reconnect(): static
161    {
162        $this->disconnect();
163
164        return $this->connect();
165    }
166
167    public function ping(): bool
168    {
169        if ($this->pdo === null) {
170            return false;
171        }
172
173        try {
174            $stmt = $this->pdo->query('SELECT 1');
175
176            if ($stmt === false) {
177                return false;
178            }
179
180            $this->touchConnection();
181
182            return $stmt->fetchColumn() !== false;
183        } catch (Throwable) {
184            return false;
185        }
186    }
187
188    /**
189     * Brings the connection back to a clean state after a unit of work, such
190     * as a request in a long-running worker: an open transaction is rolled
191     * back. A connection that cannot be rolled back is dropped instead of
192     * throwing; the next statement connects anew.
193     *
194     * If an operation failed during the unit of work, the connection is
195     * pinged and dropped if it is broken. The failure may have been an
196     * ordinary SQL error or a lost connection, and MySQL does not reflect a
197     * lost connection in its transaction state, so without the ping a
198     * connection used continuously would never be replaced.
199     *
200     * Returns whether an open connection is kept for reuse.
201     */
202    public function reset(): bool
203    {
204        if ($this->pdo === null) {
205            return false;
206        }
207
208        if ($this->rollBackOpenTransaction($this->pdo) && $this->checkAfterFailure()) {
209            return true;
210        }
211
212        $this->drop();
213
214        return false;
215    }
216
217    /**
218     * Pings the connection if an operation failed on it since the last
219     * reset. Returns whether the connection can be kept.
220     */
221    private function checkAfterFailure(): bool
222    {
223        if (!$this->suspect) {
224            return true;
225        }
226
227        $this->suspect = false;
228
229        return $this->ping();
230    }
231
232    /** Returns false if the rollback failed. */
233    private function rollBackOpenTransaction(PDO $pdo): bool
234    {
235        try {
236            if ($pdo->inTransaction()) {
237                $pdo->rollBack();
238                // Only the rollback reached the server; a reset without one
239                // must not hide a long idle period from the idle check.
240                $this->touchConnection();
241            }
242
243            return true;
244        } catch (Throwable) {
245            return false;
246        }
247    }
248
249    /**
250     * Records that an operation failed on the given connection, so that the
251     * next reset() verifies it. A query may still hold a connection that was
252     * replaced since; its failures do not concern the current one.
253     *
254     * @internal
255     */
256    public function markSuspect(PDO $pdo): void
257    {
258        if ($pdo === $this->pdo) {
259            $this->suspect = true;
260        }
261    }
262
263    public function quote(string $value): string
264    {
265        $quoted = $this->requirePdo()->quote($value);
266
267        if ($quoted === false) {
268            throw new RuntimeException("PDO driver '{$this->conn->config->driver}' cannot quote values");
269        }
270
271        return $quoted;
272    }
273
274    public function begin(): bool
275    {
276        return $this->transactionCall(static fn(PDO $pdo): bool => $pdo->beginTransaction());
277    }
278
279    public function commit(): bool
280    {
281        return $this->transactionCall(static fn(PDO $pdo): bool => $pdo->commit());
282    }
283
284    public function rollback(): bool
285    {
286        return $this->transactionCall(static fn(PDO $pdo): bool => $pdo->rollBack());
287    }
288
289    /** @param Closure(PDO): bool $call */
290    private function transactionCall(Closure $call): bool
291    {
292        $pdo = $this->requirePdo();
293
294        try {
295            return $call($pdo);
296        } catch (PDOException $e) {
297            $this->markSuspect($pdo);
298
299            throw $e;
300        }
301    }
302
303    public function getConn(): PDO
304    {
305        return $this->requirePdo();
306    }
307
308    protected function requirePdo(): PDO
309    {
310        $this->checkReuse();
311        $this->connect();
312        $pdo = $this->pdo;
313
314        if ($pdo !== null) {
315            $this->touchConnection();
316
317            return $pdo;
318        }
319
320        throw new RuntimeException('Database connection not initialized');
321    }
322
323    /**
324     * Before an existing connection is used after a pause, replace it if it
325     * reached the maximum age, or ping it if it was idle long enough and drop
326     * it if it is broken. Never inside a transaction: a new connection would
327     * silently lose the transaction's work, so a statement on a broken
328     * connection fails instead. Statements themselves are never retried.
329     */
330    protected function checkReuse(): void
331    {
332        if ($this->pdo === null || $this->pdo->inTransaction()) {
333            return;
334        }
335
336        $config = $this->conn->config;
337        $now = time();
338
339        if ($config->maxConnectionAge > 0 && ($now - ($this->connectedAt ?? $now)) >= $config->maxConnectionAge) {
340            $this->drop();
341
342            return;
343        }
344
345        if (
346            $config->pingAfterIdle > 0
347            && ($now - ($this->lastUsedAt ?? $now)) >= $config->pingAfterIdle
348            && !$this->ping()
349        ) {
350            $this->drop();
351        }
352    }
353
354    protected function drop(): void
355    {
356        $this->pdo = null;
357        $this->connectedAt = null;
358        $this->lastUsedAt = null;
359        $this->suspect = false;
360    }
361
362    protected function markConnected(): void
363    {
364        $now = time();
365        $this->connectedAt = $now;
366        $this->lastUsedAt = $now;
367    }
368
369    protected function touchConnection(): void
370    {
371        if ($this->pdo !== null) {
372            $this->lastUsedAt = time();
373        }
374    }
375
376    public function execute(string $query, mixed ...$args): Query
377    {
378        return new Query($this, $query, new Args($args), null);
379    }
380}