Lines 100.00% 45 / 45
Methods 100.00% 7 / 7
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 find 100.00% 1 / 1 100.00% 1 / 1 1
 keyMatches 100.00% 12 / 12 100.00% 1 / 1 10
 normalizeKey 100.00% 3 / 3 100.00% 1 / 1 3
 modelKeys 100.00% 4 / 4 100.00% 1 / 1 2
 load 100.00% 7 / 7 100.00% 1 / 1 3
 fresh 100.00% 17 / 17 100.00% 1 / 1 5
 serializeKeyValue 100.00% 1 / 1 100.00% 1 / 1 2
20final class Collection extends BaseCollection
21{
22    /**
23     * Find a model in the collection by its primary key.
24     *
25     * A composite key matches by shape — the same column set, compared
26     * pair-wise — so map ordering never matters.
27     *
28     * @param  KeyValue  $key
29     * @return TValue|null
30     */
31    public function find(int|string|null|array $key): ?Model
32    {
33        /** @var TValue|null */
34        return $this->first(fn (Model $model) => self::keyMatches($model->getKeyForRefresh(), $key));
35    }
36
37    /**
38     * Compare two primary-key values for a {@see Collection::find()} match.
39     *
40     * @param  KeyValue  $modelKey
41     * @param  KeyValue  $key
42     * @return bool
43     */
44    private static function keyMatches(int|string|null|array $modelKey, int|string|null|array $key): bool
45    {
46        if (is_array($modelKey)) {
47            if (!is_array($key) || count($modelKey) !== count($key)) {
48                return false;
49            }
50
51            foreach ($modelKey as $column => $value) {
52                if (!array_key_exists($column, $key) || !self::keyMatches($value, $key[$column])) {
53                    return false;
54                }
55            }
56
57            return true;
58        }
59
60        if (is_array($key)) {
61            return false;
62        }
63
64        if ($modelKey === null || $key === null) {
65            return $modelKey === $key;
66        }
67
68        // Strict after int/numeric-string normalization. PK values
69        // round-trip dialect bytes through the codec, so `42` must match
70        // `'42'` — but plain `==` over-matched: `find(0)` matched the key
71        // `'0e1'` (scientific notation, `== 0`) and `true` matched `'1'`.
72        // Both sides normalize numeric strings to int before a strict
73        // compare; non-numeric scalars compare strictly as-is.
74        return self::normalizeKey($modelKey) === self::normalizeKey($key);
75    }
76
77    /**
78     * Normalize a scalar PK value for strict comparison — numeric strings
79     * collapse to int, everything else passes through.
80     *
81     * @param  int|string|null  $value
82     * @return int|string|null
83     */
84    private static function normalizeKey(int|string|null $value): int|string|null
85    {
86        if (is_string($value) && preg_match('/^-?\d+$/', $value) === 1) {
87            return (int) $value;
88        }
89
90        return $value;
91    }
92
93    /**
94     * Every model's primary-key value.
95     *
96     * @return list<KeyValue>
97     */
98    public function modelKeys(): array
99    {
100        $keys = [];
101
102        foreach ($this->items as $model) {
103            $keys[] = $model->getKeyForRefresh();
104        }
105
106        /** @var list<KeyValue> */
107        return $keys;
108    }
109
110    /**
111     * Eager-load relations on every model in the collection.
112     *
113     * @param  string  ...$relations
114     * @return static
115     * @throws \InvalidArgumentException
116     */
117    public function load(string ...$relations): static
118    {
119        if ($this->items === []) {
120            return $this;
121        }
122
123        /** @var TValue $first */
124        $first = $this->first();
125
126        $query = $first->newQuery();
127
128        foreach ($relations as $path) {
129            $query->loadRelationPath($this, $path);
130        }
131
132        return $this;
133    }
134
135    /**
136     * Re-query every model by its key and replace the items.
137     *
138     * ONE query, not N: the keys go into a single `whereKey(...)` and the
139     * re-hydrated rows are re-attached to the collection's original
140     * positions by serialized key — a row deleted externally leaves its
141     * original model in place. Registered eager loads are not re-applied.
142     *
143     * @return static
144     */
145    public function fresh(): static
146    {
147        if ($this->items === []) {
148            return $this;
149        }
150
151        /** @var TValue $first */
152        $first = $this->first();
153
154        $query = $first->newQuery()->withTrashed();
155
156        /** @var list<KeyValue> $keys */
157        $keys = [];
158
159        foreach ($this->items as $model) {
160            $keys[] = $model->getKeyForRefresh();
161        }
162
163        $query = $query->whereKey($keys);
164
165        $freshBySerializedKey = [];
166
167        foreach ($query->get() as $fresh) {
168            $freshBySerializedKey[self::serializeKeyValue($fresh->getKeyForRefresh())] = $fresh;
169        }
170
171        $models = [];
172
173        foreach ($this->items as $model) {
174            $serialized = self::serializeKeyValue($model->getKeyForRefresh());
175            $models[] = $freshBySerializedKey[$serialized] ?? $model;
176        }
177
178        /** @var list<TValue> $models */
179        $this->items = $models;
180
181        return $this;
182    }
183
184    /**
185     * Serialize a key value to a stable string — scalars stringify;
186     * composite maps JSON-encode.
187     *
188     * @param  KeyValue  $key
189     * @return string
190     * @throws \JsonException
191     */
192    private static function serializeKeyValue(int|string|null|array $key): string
193    {
194        return is_array($key) ? json_encode($key, JSON_THROW_ON_ERROR) : (string) $key;
195    }
196}