Lines
100.00%
45 / 45
Functions and Methods
100.00%
7 / 7
Classes and Traits
100.00%
1 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Collection | 100.00% | 45 / 45 | 100.00% | 7 / 7 | 26 | 100.00% | 1 / 1 | |||
| 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 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace BlueprintAU\Radiant; | |
| 6 | ||
| 7 | use BlueprintAU\Collections\Collection as BaseCollection; | |
| 8 | ||
| 9 | /** | |
| 10 | * Model-aware subclass of the base {@see BaseCollection}. | |
| 11 | * | |
| 12 | * A collection is a list by default; `keyBy()`/`groupBy()` results are the | |
| 13 | * keyed shape, carrying their key type as `TKey`. | |
| 14 | * | |
| 15 | * @template TKey of array-key | |
| 16 | * @template TValue of Model | |
| 17 | * @extends BaseCollection<TKey, TValue> | |
| 18 | * @phpstan-import-type KeyValue from \BlueprintAU\Radiant\Model | |
| 19 | */ | |
| 20 | final 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 | } |