Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.32% covered (warning)
60.32%
38 / 63
41.67% covered (danger)
41.67%
5 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Row
60.32% covered (warning)
60.32%
38 / 63
41.67% covered (danger)
41.67%
5 / 12
164.98
0.00% covered (danger)
0.00%
0 / 1
 from
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
2.86
 int
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 nullableInt
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 string
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
7.91
 nullableString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 float
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
6.32
 nullableFloat
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 bool
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
44.30
 nullableBool
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 array
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
2.50
 nullableArray
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 require
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2
3declare(strict_types=1);
4
5namespace App\Support;
6
7use InvalidArgumentException;
8
9/**
10 * Typed accessors for PDO row arrays.
11 *
12 * PDO returns rows as `array<string, mixed>`. PHPStan level 9 forbids casting
13 * `mixed` to a typed scalar, so DTO fromRow() factories that did `(int) $row['id']`
14 * no longer type-check. This helper centralises the cast + validation pattern
15 * so DTOs can stay declarative while still satisfying static analysis and
16 * giving a real runtime error when the database shape diverges from expectations.
17 *
18 * Postgres returns NUMERIC values as PHP strings, so int columns may arrive as
19 * either string or int depending on the column type. The helpers accept both.
20 */
21final class Row
22{
23    /**
24     * Narrow a mixed value (typically from PDO::fetch / fetchAll) to a row
25     * array. Throws if the value is not an array - useful at the boundary
26     * between PHPStan's mixed PDO stubs and the typed Row accessors below.
27     *
28     * @param mixed $value
29     * @return array<mixed>
30     */
31    public static function from(mixed $value): array
32    {
33        if (!is_array($value)) {
34            throw new InvalidArgumentException(
35                'Expected array row, got ' . get_debug_type($value),
36            );
37        }
38
39        return $value;
40    }
41
42    /**
43     * @param array<mixed> $row
44     * @param string $key
45     */
46    public static function int(array $row, string $key): int
47    {
48        $value = self::require($row, $key);
49
50        if (is_int($value)) {
51            return $value;
52        }
53
54        if (is_string($value) && is_numeric($value)) {
55            return (int)$value;
56        }
57
58        throw new InvalidArgumentException(
59            "Row key '{$key}' must be an int or numeric string, got " . get_debug_type($value),
60        );
61    }
62
63    /**
64     * @param array<mixed> $row
65     * @param string $key
66     */
67    public static function nullableInt(array $row, string $key): ?int
68    {
69        if (!array_key_exists($key, $row) || $row[$key] === null) {
70            return null;
71        }
72
73        return self::int($row, $key);
74    }
75
76    /**
77     * @param array<mixed> $row
78     * @param string $key
79     */
80    public static function string(array $row, string $key): string
81    {
82        $value = self::require($row, $key);
83
84        if (is_string($value)) {
85            return $value;
86        }
87
88        if (is_int($value) || is_float($value)) {
89            return (string)$value;
90        }
91
92        throw new InvalidArgumentException(
93            "Row key '{$key}' must be a string or numeric, got " . get_debug_type($value),
94        );
95    }
96
97    /**
98     * @param array<mixed> $row
99     * @param string $key
100     */
101    public static function nullableString(array $row, string $key): ?string
102    {
103        if (!array_key_exists($key, $row) || $row[$key] === null) {
104            return null;
105        }
106
107        return self::string($row, $key);
108    }
109
110    /**
111     * @param array<mixed> $row
112     * @param string $key
113     */
114    public static function float(array $row, string $key): float
115    {
116        $value = self::require($row, $key);
117
118        if (is_float($value) || is_int($value)) {
119            return (float)$value;
120        }
121
122        if (is_string($value) && is_numeric($value)) {
123            return (float)$value;
124        }
125
126        throw new InvalidArgumentException(
127            "Row key '{$key}' must be a float, int, or numeric string, got " . get_debug_type($value),
128        );
129    }
130
131    /**
132     * @param array<mixed> $row
133     * @param string $key
134     */
135    public static function nullableFloat(array $row, string $key): ?float
136    {
137        if (!array_key_exists($key, $row) || $row[$key] === null) {
138            return null;
139        }
140
141        return self::float($row, $key);
142    }
143
144    /**
145     * @param array<mixed> $row
146     * @param string $key
147     */
148    public static function bool(array $row, string $key): bool
149    {
150        $value = self::require($row, $key);
151
152        if (is_bool($value)) {
153            return $value;
154        }
155
156        // Postgres returns booleans as 't'/'f' strings via some drivers, and
157        // smallint columns may carry 0/1. Accept the common encodings.
158        if ($value === 1 || $value === '1' || $value === 't' || $value === 'true') {
159            return true;
160        }
161
162        if ($value === 0 || $value === '0' || $value === 'f' || $value === 'false') {
163            return false;
164        }
165
166        throw new InvalidArgumentException(
167            "Row key '{$key}' must be a bool-compatible value, got " . get_debug_type($value),
168        );
169    }
170
171    /**
172     * @param array<mixed> $row
173     * @param string $key
174     */
175    public static function nullableBool(array $row, string $key): ?bool
176    {
177        if (!array_key_exists($key, $row) || $row[$key] === null) {
178            return null;
179        }
180
181        return self::bool($row, $key);
182    }
183
184    /**
185     * @param array<mixed> $row
186     * @param string $key
187     *
188     * @return array<mixed>
189     */
190    public static function array(array $row, string $key): array
191    {
192        $value = self::require($row, $key);
193
194        if (is_array($value)) {
195            return $value;
196        }
197
198        throw new InvalidArgumentException(
199            "Row key '{$key}' must be an array, got " . get_debug_type($value),
200        );
201    }
202
203    /**
204     * @param array<mixed> $row
205     * @param string $key
206     *
207     * @return array<mixed>|null
208     */
209    public static function nullableArray(array $row, string $key): ?array
210    {
211        if (!array_key_exists($key, $row) || $row[$key] === null) {
212            return null;
213        }
214
215        return self::array($row, $key);
216    }
217
218    /**
219     * @param array<mixed> $row
220     * @param string $key
221     */
222    private static function require(array $row, string $key): mixed
223    {
224        if (!array_key_exists($key, $row)) {
225            throw new InvalidArgumentException("Row is missing required key '{$key}'");
226        }
227
228        return $row[$key];
229    }
230}