Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
124 / 124
98.10% covered (success)
98.10%
103 / 105
44.19% covered (danger)
44.19%
38 / 86
91.67% covered (success)
91.67%
22 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Query
100.00% covered (success)
100.00%
124 / 124
98.10% covered (success)
98.10%
103 / 105
44.19% covered (danger)
44.19%
38 / 86
100.00% covered (success)
100.00%
24 / 24
561.01
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
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
 __toString
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
 one
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
7 / 7
25.00% covered (danger)
25.00%
1 / 4
100.00% covered (success)
100.00%
1 / 1
6.80
 first
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
6 / 6
0.00% covered (danger)
0.00%
0 / 4
100.00% covered (success)
100.00%
1 / 1
2
 fetch
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 all
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
9 / 9
0.00% covered (danger)
0.00%
0 / 8
100.00% covered (success)
100.00%
1 / 1
3
 lazy
100.00% covered (success)
100.00%
8 / 8
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 6
100.00% covered (success)
100.00%
1 / 1
3
 terminalOptions
100.00% covered (success)
100.00%
8 / 8
92.31% covered (success)
92.31%
12 / 13
15.00% covered (danger)
15.00%
3 / 20
100.00% covered (success)
100.00%
1 / 1
20.35
 executeFresh
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
 executeForFetch
100.00% covered (success)
100.00%
3 / 3
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
 executeStatement
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
 fail
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
 hydrateRecord
100.00% covered (success)
100.00%
3 / 3
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
 hydrator
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
 run
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
 len
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
3 / 3
0.00% covered (danger)
0.00%
0 / 2
100.00% covered (success)
100.00%
1 / 1
1
 interpolate
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
 prepare
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
6 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 useCurrentConnection
100.00% covered (success)
100.00%
3 / 3
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
 bindArgs
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 bindValue
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
17 / 17
53.85% covered (warning)
53.85%
7 / 13
100.00% covered (success)
100.00%
1 / 1
14.29
 fetchArrayRecord
100.00% covered (success)
100.00%
3 / 3
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
 fetchAllRecords
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
 nullIfNot
100.00% covered (success)
100.00%
3 / 3
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
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Quma;
6
7use Celema\Quma\Exception\UnexpectedResultCount;
8use Celema\Quma\Hydration\Hydrator;
9use Closure;
10use Generator;
11use InvalidArgumentException;
12use JsonException;
13use PDO;
14use PDOException;
15use PDOStatement;
16
17/** @api */
18class Query
19{
20    // Matches multi line single and double quotes and handles \' \" escapes
21    public const string PATTERN_STRING = '/([\'"])(?:\\\1|[\s\S])*?\1/';
22
23    // PostgreSQL blocks delimited with $$
24    public const string PATTERN_BLOCK = '/(\$\$)[\s\S]*?\1/';
25
26    // Multi line comments /* */
27    public const string PATTERN_COMMENT_MULTI = '/\/\*([\s\S]*?)\*\//';
28
29    // Single line comments --
30    public const string PATTERN_COMMENT_SINGLE = '/--.*$/m';
31
32    protected PDOStatement $stmt;
33    protected bool $executed = false;
34    protected ?Hydrator $hydrator = null;
35
36    /** The connection the statement was prepared on. */
37    private PDO $pdo;
38
39    public function __construct(
40        protected Database $db,
41        protected string $query,
42        protected Args $args,
43        protected ?string $sourcePath = null,
44    ) {
45        $this->prepare($this->db->getConn());
46
47        if ($this->db->debug) {
48            Debug::query($this->db, $this->query, $this->args, $this->sourcePath);
49        }
50    }
51
52    public function __toString(): string
53    {
54        return $this->interpolate();
55    }
56
57    /**
58     * @template T of object
59     *
60     * @param class-string<T>|Closure(array<string, mixed>):class-string<T>|null $map
61     * @return ($map is null ? array<array-key, mixed> : T)
62     */
63    public function one(string|Closure|null $map = null, ?int $fetchMode = null): array|object
64    {
65        $fetchMode = $this->terminalOptions($map, $fetchMode);
66        $this->executeFresh();
67
68        try {
69            $record = $this->fetchArrayRecord($fetchMode);
70
71            if ($record === null) {
72                throw UnexpectedResultCount::none();
73            }
74
75            if ($this->fetchArrayRecord($fetchMode) !== null) {
76                throw UnexpectedResultCount::multiple();
77            }
78
79            return $this->hydrateRecord($record, $map);
80        } finally {
81            $this->stmt->closeCursor();
82        }
83    }
84
85    /**
86     * @template T of object
87     *
88     * @param class-string<T>|Closure(array<string, mixed>):class-string<T>|null $map
89     * @return ($map is null ? array<array-key, mixed>|null : T|null)
90     */
91    public function first(string|Closure|null $map = null, ?int $fetchMode = null): array|object|null
92    {
93        $fetchMode = $this->terminalOptions($map, $fetchMode);
94        $this->executeFresh();
95
96        try {
97            $record = $this->fetchArrayRecord($fetchMode);
98
99            return $record === null ? null : $this->hydrateRecord($record, $map);
100        } finally {
101            $this->stmt->closeCursor();
102        }
103    }
104
105    /**
106     * @template T of object
107     *
108     * @param class-string<T>|Closure(array<string, mixed>):class-string<T>|null $map
109     * @return ($map is null ? array<array-key, mixed>|null : T|null)
110     */
111    public function fetch(string|Closure|null $map = null, ?int $fetchMode = null): array|object|null
112    {
113        $fetchMode = $this->terminalOptions($map, $fetchMode);
114        $this->executeForFetch();
115
116        $record = $this->fetchArrayRecord($fetchMode);
117
118        return $record === null ? null : $this->hydrateRecord($record, $map);
119    }
120
121    /**
122     * @template T of object
123     *
124     * @param class-string<T>|Closure(array<string, mixed>):class-string<T>|null $map
125     * @return ($map is null ? list<array<array-key, mixed>> : list<T>)
126     */
127    public function all(string|Closure|null $map = null, ?int $fetchMode = null): array
128    {
129        $fetchMode = $this->terminalOptions($map, $fetchMode);
130        $this->executeFresh();
131
132        try {
133            $records = $this->fetchAllRecords($fetchMode);
134
135            if ($map === null) {
136                return $records;
137            }
138
139            $result = [];
140
141            foreach ($records as $record) {
142                $result[] = $this->hydrator()->hydrate($record, $map, $this->sourcePath);
143            }
144
145            return $result;
146        } finally {
147            $this->stmt->closeCursor();
148        }
149    }
150
151    /**
152     * @template T of object
153     *
154     * @param class-string<T>|Closure(array<string, mixed>):class-string<T>|null $map
155     * @return ($map is null
156     *     ? Generator<int, array<array-key, mixed>, mixed, void>
157     *     : Generator<int, T, mixed, void>)
158     */
159    public function lazy(string|Closure|null $map = null, ?int $fetchMode = null): Generator
160    {
161        $fetchMode = $this->terminalOptions($map, $fetchMode);
162        $this->executeFresh();
163
164        try {
165            while (($record = $this->fetchArrayRecord($fetchMode)) !== null) {
166                if ($map === null) {
167                    yield $record;
168
169                    continue;
170                }
171
172                yield $this->hydrator()->hydrate($record, $map, $this->sourcePath);
173            }
174        } finally {
175            $this->stmt->closeCursor();
176        }
177    }
178
179    private function terminalOptions(string|Closure|null $map, ?int $fetchMode): int
180    {
181        $mode = $fetchMode ?? ($map === null ? $this->db->getFetchMode() : PDO::FETCH_ASSOC);
182
183        if ($map !== null && $mode !== PDO::FETCH_ASSOC) {
184            throw new InvalidArgumentException('Hydration requires PDO::FETCH_ASSOC.');
185        }
186
187        if (!in_array($mode, [PDO::FETCH_ASSOC, PDO::FETCH_NUM, PDO::FETCH_BOTH, PDO::FETCH_NAMED], true)) {
188            throw new InvalidArgumentException(
189                'Unsupported fetch mode. Use PDO::FETCH_ASSOC, PDO::FETCH_NUM, PDO::FETCH_BOTH, or PDO::FETCH_NAMED.',
190            );
191        }
192
193        return $mode;
194    }
195
196    private function executeFresh(): void
197    {
198        $this->executeStatement();
199        $this->executed = false;
200    }
201
202    private function executeForFetch(): void
203    {
204        if (!$this->executed) {
205            $this->executeStatement();
206            $this->executed = true;
207        }
208    }
209
210    private function executeStatement(): bool
211    {
212        $this->useCurrentConnection();
213
214        try {
215            $this->stmt->closeCursor();
216
217            return $this->stmt->execute();
218        } catch (PDOException $e) {
219            $this->fail($this->pdo, $e);
220        }
221    }
222
223    /**
224     * Lets the database verify the connection before it is reused, as the
225     * failure may mean that the connection was lost. The failed operation
226     * itself is never retried.
227     */
228    private function fail(PDO $pdo, PDOException $e): never
229    {
230        $this->db->markSuspect($pdo);
231
232        throw $e;
233    }
234
235    /**
236     * @template T of object
237     *
238     * @param array<array-key, mixed> $record
239     * @param class-string<T>|Closure(array<string, mixed>):class-string<T>|null $map
240     * @return ($map is null ? array<array-key, mixed> : T)
241     */
242    private function hydrateRecord(array $record, string|Closure|null $map): array|object
243    {
244        if ($map === null) {
245            return $record;
246        }
247
248        return $this->hydrator()->hydrate($record, $map, $this->sourcePath);
249    }
250
251    private function hydrator(): Hydrator
252    {
253        return $this->hydrator ??= Hydrator::default();
254    }
255
256    public function run(): bool
257    {
258        $this->executed = false;
259
260        return $this->executeStatement();
261    }
262
263    public function len(): int
264    {
265        $this->executeFresh();
266
267        try {
268            return $this->stmt->rowCount();
269        } finally {
270            $this->stmt->closeCursor();
271        }
272    }
273
274    /**
275     * For debugging purposes only.
276     *
277     * Replaces any parameter placeholders in a query with the
278     * value of that parameter and returns the query as string.
279     *
280     * Covers most of the cases but is not perfect.
281     */
282    public function interpolate(): string
283    {
284        return Debug::interpolate($this->query, $this->args);
285    }
286
287    private function prepare(PDO $pdo): void
288    {
289        try {
290            $stmt = $pdo->prepare($this->query);
291        } catch (PDOException $e) {
292            $this->fail($pdo, $e);
293        }
294
295        // Only a prepared statement switches the query to the connection;
296        // otherwise its next execution would not prepare it again.
297        $this->pdo = $pdo;
298        $this->stmt = $stmt;
299
300        if ($this->args->count() > 0) {
301            $this->bindArgs($this->args->get(), $this->args->type());
302        }
303    }
304
305    /**
306     * Before an execution, prepares the statement again if the database
307     * replaced its connection since, for example after the maximum age. The
308     * statement would otherwise still run on the old connection, outside a
309     * transaction begun on the new one.
310     */
311    private function useCurrentConnection(): void
312    {
313        $pdo = $this->db->getConn();
314
315        if ($pdo !== $this->pdo) {
316            $this->prepare($pdo);
317        }
318    }
319
320    /** @param array<array-key, mixed> $args */
321    protected function bindArgs(array $args, ArgType $argType): void
322    {
323        array_walk(
324            $args,
325            function (mixed $value, int|string $index) use ($argType): void {
326                if ($argType === ArgType::Named) {
327                    $arg = ':' . $index;
328                } else {
329                    $arg = (int) $index + 1; // question mark placeholders are 1-indexed
330                }
331
332                $this->bindValue($arg, $value);
333            },
334        );
335    }
336
337    protected function bindValue(string|int $arg, mixed $value): void
338    {
339        switch (gettype($value)) {
340            case 'boolean':
341                $this->stmt->bindValue($arg, $value, PDO::PARAM_BOOL);
342
343                break;
344
345            case 'integer':
346                $this->stmt->bindValue($arg, $value, PDO::PARAM_INT);
347
348                break;
349
350            case 'string':
351                $this->stmt->bindValue($arg, $value, PDO::PARAM_STR);
352
353                break;
354
355            case 'NULL':
356                $this->stmt->bindValue($arg, $value, PDO::PARAM_NULL);
357
358                break;
359
360            case 'array':
361                try {
362                    $json = json_encode($value, JSON_THROW_ON_ERROR);
363                } catch (JsonException $e) {
364                    throw new InvalidArgumentException(
365                        'Array parameters must be JSON-encodable.',
366                        previous: $e,
367                    );
368                }
369
370                $this->stmt->bindValue($arg, $json, PDO::PARAM_STR);
371
372                break;
373
374            default:
375                throw new InvalidArgumentException(
376                    'Only the types bool, int, string, null and array are supported',
377                );
378        }
379    }
380
381    /** @return array<array-key, mixed>|null */
382    protected function fetchArrayRecord(int $fetchMode): ?array
383    {
384        try {
385            return $this->nullIfNot($this->stmt->fetch($fetchMode));
386        } catch (PDOException $e) {
387            $this->fail($this->pdo, $e);
388        }
389    }
390
391    /** @return list<array<array-key, mixed>> */
392    private function fetchAllRecords(int $fetchMode): array
393    {
394        try {
395            /**
396             * @mago-expect lint:inline-variable-return Psalm makes this necessary
397             * @var list<array<array-key, mixed>> $records
398             */
399            $records = $this->stmt->fetchAll($fetchMode);
400
401            return $records;
402        } catch (PDOException $e) {
403            $this->fail($this->pdo, $e);
404        }
405    }
406
407    /** @return array<array-key, mixed>|null */
408    protected function nullIfNot(mixed $value): ?array
409    {
410        if (is_array($value)) {
411            return $value;
412        }
413
414        return null;
415    }
416}