Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
109 / 109
98.81% covered (success)
98.81%
83 / 84
43.75% covered (danger)
43.75%
28 / 64
94.74% covered (success)
94.74%
18 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Query
100.00% covered (success)
100.00%
109 / 109
98.81% covered (success)
98.81%
83 / 84
43.75% covered (danger)
43.75%
28 / 64
100.00% covered (success)
100.00%
19 / 19
372.08
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
5 / 5
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 __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%
12 / 12
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%
9 / 9
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%
4 / 4
100.00% covered (success)
100.00%
8 / 8
25.00% covered (danger)
25.00%
2 / 8
100.00% covered (success)
100.00%
1 / 1
10.75
 executeFresh
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
 executeForFetch
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
 hydrateRecord
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
 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%
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
 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
 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%
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
 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 Celemas\Quma;
6
7use Celemas\Quma\Exception\UnexpectedResultCount;
8use Celemas\Quma\Hydration\Hydrator;
9use Closure;
10use Generator;
11use InvalidArgumentException;
12use JsonException;
13use PDO;
14use PDOStatement;
15
16/** @api */
17class Query
18{
19    // Matches multi line single and double quotes and handles \' \" escapes
20    public const string PATTERN_STRING = '/([\'"])(?:\\\1|[\s\S])*?\1/';
21
22    // PostgreSQL blocks delimited with $$
23    public const string PATTERN_BLOCK = '/(\$\$)[\s\S]*?\1/';
24
25    // Multi line comments /* */
26    public const string PATTERN_COMMENT_MULTI = '/\/\*([\s\S]*?)\*\//';
27
28    // Single line comments --
29    public const string PATTERN_COMMENT_SINGLE = '/--.*$/m';
30
31    protected PDOStatement $stmt;
32    protected bool $executed = false;
33    protected ?Hydrator $hydrator = null;
34
35    public function __construct(
36        protected Database $db,
37        protected string $query,
38        protected Args $args,
39        protected ?string $sourcePath = null,
40    ) {
41        $this->stmt = $this->db->getConn()->prepare($query);
42
43        if ($args->count() > 0) {
44            $this->bindArgs($args->get(), $args->type());
45        }
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        [$map, $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        [$map, $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        [$map, $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        [$map, $fetchMode] = $this->terminalOptions($map, $fetchMode);
130        $this->executeFresh();
131
132        try {
133            if ($map === null) {
134                /**
135                 * @mago-expect lint:inline-variable-return Psalm makes this necessary
136                 * @var list<array<array-key, mixed>> $records
137                 */
138                $records = $this->stmt->fetchAll($fetchMode);
139
140                return $records;
141            }
142
143            /** @var list<T> $result */
144            $result = [];
145            /** @var list<array<array-key, mixed>> $records */
146            $records = $this->stmt->fetchAll($fetchMode);
147
148            foreach ($records as $record) {
149                /** @var T $object */
150                $object = $this->hydrator()->hydrate($record, $map, $this->sourcePath);
151                $result[] = $object;
152            }
153
154            return $result;
155        } finally {
156            $this->stmt->closeCursor();
157        }
158    }
159
160    /**
161     * @template T of object
162     *
163     * @param class-string<T>|Closure(array<string, mixed>):class-string<T>|null $map
164     * @return ($map is null
165     *     ? Generator<int, array<array-key, mixed>, mixed, void>
166     *     : Generator<int, T, mixed, void>)
167     */
168    public function lazy(string|Closure|null $map = null, ?int $fetchMode = null): Generator
169    {
170        [$map, $fetchMode] = $this->terminalOptions($map, $fetchMode);
171        $this->executeFresh();
172
173        try {
174            while (($record = $this->fetchArrayRecord($fetchMode)) !== null) {
175                if ($map === null) {
176                    yield $record;
177
178                    continue;
179                }
180
181                /** @var T $object */
182                $object = $this->hydrator()->hydrate($record, $map, $this->sourcePath);
183
184                yield $object;
185            }
186        } finally {
187            $this->stmt->closeCursor();
188        }
189    }
190
191    /**
192     * @return array{0: string|Closure|null, 1: int}
193     */
194    private function terminalOptions(string|Closure|null $map, ?int $fetchMode): array
195    {
196        $mode = $fetchMode ?? ($map === null ? $this->db->getFetchMode() : PDO::FETCH_ASSOC);
197
198        if ($map !== null && $mode !== PDO::FETCH_ASSOC) {
199            throw new InvalidArgumentException('Hydration requires PDO::FETCH_ASSOC.');
200        }
201
202        return [$map, $mode];
203    }
204
205    private function executeFresh(): void
206    {
207        $this->db->connect();
208        $this->stmt->closeCursor();
209        $this->stmt->execute();
210        $this->executed = false;
211    }
212
213    private function executeForFetch(): void
214    {
215        $this->db->connect();
216
217        if (!$this->executed) {
218            $this->stmt->closeCursor();
219            $this->stmt->execute();
220            $this->executed = true;
221        }
222    }
223
224    /**
225     * @template T of object
226     *
227     * @param array<array-key, mixed> $record
228     * @param string|Closure(array<string, mixed>):class-string<T>|null $map
229     * @return ($map is null ? array<array-key, mixed> : T)
230     */
231    private function hydrateRecord(array $record, string|Closure|null $map): array|object
232    {
233        if ($map === null) {
234            return $record;
235        }
236
237        /**
238         * @mago-expect lint:inline-variable-return Psalm makes this necessary
239         * @var T $object
240         */
241        $object = $this->hydrator()->hydrate($record, $map, $this->sourcePath);
242
243        return $object;
244    }
245
246    private function hydrator(): Hydrator
247    {
248        return $this->hydrator ??= Hydrator::default();
249    }
250
251    public function run(): bool
252    {
253        $this->db->connect();
254        $this->stmt->closeCursor();
255        $this->executed = false;
256
257        return $this->stmt->execute();
258    }
259
260    public function len(): int
261    {
262        $this->executeFresh();
263
264        try {
265            return $this->stmt->rowCount();
266        } finally {
267            $this->stmt->closeCursor();
268        }
269    }
270
271    /**
272     * For debugging purposes only.
273     *
274     * Replaces any parameter placeholders in a query with the
275     * value of that parameter and returns the query as string.
276     *
277     * Covers most of the cases but is not perfect.
278     */
279    public function interpolate(): string
280    {
281        return Debug::interpolate($this->query, $this->args);
282    }
283
284    protected function bindArgs(array $args, ArgType $argType): void
285    {
286        array_walk(
287            $args,
288            function (mixed $value, int|string $index) use ($argType): void {
289                if ($argType === ArgType::Named) {
290                    $arg = ':' . $index;
291                } else {
292                    $arg = (int) $index + 1; // question mark placeholders are 1-indexed
293                }
294
295                $this->bindValue($arg, $value);
296            },
297        );
298    }
299
300    protected function bindValue(string|int $arg, mixed $value): void
301    {
302        switch (gettype($value)) {
303            case 'boolean':
304                $this->stmt->bindValue($arg, $value, PDO::PARAM_BOOL);
305
306                break;
307
308            case 'integer':
309                $this->stmt->bindValue($arg, $value, PDO::PARAM_INT);
310
311                break;
312
313            case 'string':
314                $this->stmt->bindValue($arg, $value, PDO::PARAM_STR);
315
316                break;
317
318            case 'NULL':
319                $this->stmt->bindValue($arg, $value, PDO::PARAM_NULL);
320
321                break;
322
323            case 'array':
324                try {
325                    $json = json_encode($value, JSON_THROW_ON_ERROR);
326                } catch (JsonException $e) {
327                    throw new InvalidArgumentException(
328                        'Array parameters must be JSON-encodable.',
329                        previous: $e,
330                    );
331                }
332
333                $this->stmt->bindValue($arg, $json, PDO::PARAM_STR);
334
335                break;
336
337            default:
338                throw new InvalidArgumentException(
339                    'Only the types bool, int, string, null and array are supported',
340                );
341        }
342    }
343
344    protected function fetchArrayRecord(int $fetchMode): ?array
345    {
346        return $this->nullIfNot($this->stmt->fetch($fetchMode));
347    }
348
349    protected function nullIfNot(mixed $value): ?array
350    {
351        if (is_array($value)) {
352            return $value;
353        }
354
355        return null;
356    }
357}