Skip to content
This repository was archived by the owner on Aug 28, 2024. It is now read-only.

Commit bc04fc6

Browse files
committed
BUGFIX: Track paths for exceptions only until first null encounter
1 parent 5b3a355 commit bc04fc6

File tree

3 files changed

+99
-34
lines changed

3 files changed

+99
-34
lines changed

src/Extractor.php

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,14 @@ final class Extractor implements \ArrayAccess, \IteratorAggregate
3434
{
3535
/**
3636
* @param null|boolean|integer|float|string|array<mixed> $data
37-
* @param (int|string)[] $path
37+
* @param (int|string)[] $pathUntilFirstNullEncounter
38+
* @param (int|string)[] $entireAccessPath
39+
* @param bool $isKey
3840
*/
3941
private function __construct(
4042
private readonly null|bool|int|float|string|array $data,
41-
private readonly array $path,
43+
private readonly array $pathUntilFirstNullEncounter,
44+
private readonly array $entireAccessPath,
4245
private readonly bool $isKey
4346
) {
4447
}
@@ -49,15 +52,20 @@ private function __construct(
4952
*/
5053
public static function for(null|bool|int|float|string|array $data): self
5154
{
52-
return new self($data, [], false);
55+
return new self($data, [], [], false);
5356
}
5457

5558
/**
5659
* @param int|string $key
5760
*/
5861
private function forKey(int|string $key): self
5962
{
60-
return new self($key, [...$this->path, $key], true);
63+
return new self(
64+
data: $key,
65+
pathUntilFirstNullEncounter: [...$this->entireAccessPath, $key],
66+
entireAccessPath: [...$this->entireAccessPath, $key],
67+
isKey: true
68+
);
6169
}
6270

6371
/**
@@ -66,7 +74,7 @@ private function forKey(int|string $key): self
6674
*/
6775
public function getPath(): array
6876
{
69-
return $this->path;
77+
return $this->entireAccessPath;
7078
}
7179

7280
/**
@@ -76,15 +84,15 @@ public function getPath(): array
7684
public function bool(): bool
7785
{
7886
if ($this->data === null) {
79-
throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->path);
87+
throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->pathUntilFirstNullEncounter);
8088
}
8189

8290
if (is_bool($this->data)) {
8391
return $this->data;
8492
}
8593

8694
throw ExtractorException::becauseDataDidNotMatchExpectedType(
87-
path: $this->path,
95+
path: $this->pathUntilFirstNullEncounter,
8896
expectedType: 'bool',
8997
attemptedData: $this->data,
9098
isKey: $this->isKey
@@ -102,7 +110,7 @@ public function boolOrNull(): bool|null
102110
}
103111

104112
throw ExtractorException::becauseDataDidNotMatchExpectedType(
105-
path: $this->path,
113+
path: $this->pathUntilFirstNullEncounter,
106114
expectedType: 'bool or null',
107115
attemptedData: $this->data,
108116
isKey: $this->isKey
@@ -116,15 +124,15 @@ public function boolOrNull(): bool|null
116124
public function int(): int
117125
{
118126
if ($this->data === null) {
119-
throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->path);
127+
throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->pathUntilFirstNullEncounter);
120128
}
121129

122130
if (is_int($this->data)) {
123131
return $this->data;
124132
}
125133

126134
throw ExtractorException::becauseDataDidNotMatchExpectedType(
127-
path: $this->path,
135+
path: $this->pathUntilFirstNullEncounter,
128136
expectedType: 'int',
129137
attemptedData: $this->data,
130138
isKey: $this->isKey
@@ -142,7 +150,7 @@ public function intOrNull(): int|null
142150
}
143151

144152
throw ExtractorException::becauseDataDidNotMatchExpectedType(
145-
path: $this->path,
153+
path: $this->pathUntilFirstNullEncounter,
146154
expectedType: 'int or null',
147155
attemptedData: $this->data,
148156
isKey: $this->isKey
@@ -156,15 +164,15 @@ public function intOrNull(): int|null
156164
public function float(): float
157165
{
158166
if ($this->data === null) {
159-
throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->path);
167+
throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->pathUntilFirstNullEncounter);
160168
}
161169

162170
if (is_float($this->data)) {
163171
return $this->data;
164172
}
165173

166174
throw ExtractorException::becauseDataDidNotMatchExpectedType(
167-
path: $this->path,
175+
path: $this->pathUntilFirstNullEncounter,
168176
expectedType: 'float',
169177
attemptedData: $this->data,
170178
isKey: $this->isKey
@@ -182,7 +190,7 @@ public function floatOrNull(): float|null
182190
}
183191

184192
throw ExtractorException::becauseDataDidNotMatchExpectedType(
185-
path: $this->path,
193+
path: $this->pathUntilFirstNullEncounter,
186194
expectedType: 'float or null',
187195
attemptedData: $this->data,
188196
isKey: $this->isKey
@@ -196,15 +204,15 @@ public function floatOrNull(): float|null
196204
public function intOrFloat(): int|float
197205
{
198206
if ($this->data === null) {
199-
throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->path);
207+
throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->pathUntilFirstNullEncounter);
200208
}
201209

202210
if (is_int($this->data) || is_float($this->data)) {
203211
return $this->data;
204212
}
205213

206214
throw ExtractorException::becauseDataDidNotMatchExpectedType(
207-
path: $this->path,
215+
path: $this->pathUntilFirstNullEncounter,
208216
expectedType: 'int or float',
209217
attemptedData: $this->data,
210218
isKey: $this->isKey
@@ -222,7 +230,7 @@ public function intOrfloatOrNull(): int|float|null
222230
}
223231

224232
throw ExtractorException::becauseDataDidNotMatchExpectedType(
225-
path: $this->path,
233+
path: $this->pathUntilFirstNullEncounter,
226234
expectedType: 'int or float or null',
227235
attemptedData: $this->data,
228236
isKey: $this->isKey
@@ -236,15 +244,15 @@ public function intOrfloatOrNull(): int|float|null
236244
public function string(): string
237245
{
238246
if ($this->data === null) {
239-
throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->path);
247+
throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->pathUntilFirstNullEncounter);
240248
}
241249

242250
if (is_string($this->data)) {
243251
return $this->data;
244252
}
245253

246254
throw ExtractorException::becauseDataDidNotMatchExpectedType(
247-
path: $this->path,
255+
path: $this->pathUntilFirstNullEncounter,
248256
expectedType: 'string',
249257
attemptedData: $this->data,
250258
isKey: $this->isKey
@@ -262,7 +270,7 @@ public function stringOrNull(): string|null
262270
}
263271

264272
throw ExtractorException::becauseDataDidNotMatchExpectedType(
265-
path: $this->path,
273+
path: $this->pathUntilFirstNullEncounter,
266274
expectedType: 'string or null',
267275
attemptedData: $this->data,
268276
isKey: $this->isKey
@@ -276,15 +284,15 @@ public function stringOrNull(): string|null
276284
public function array(): array
277285
{
278286
if ($this->data === null) {
279-
throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->path);
287+
throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->pathUntilFirstNullEncounter);
280288
}
281289

282290
if (is_array($this->data)) {
283291
return $this->data;
284292
}
285293

286294
throw ExtractorException::becauseDataDidNotMatchExpectedType(
287-
path: $this->path,
295+
path: $this->pathUntilFirstNullEncounter,
288296
expectedType: 'array',
289297
attemptedData: $this->data,
290298
isKey: $this->isKey
@@ -302,7 +310,7 @@ public function arrayOrNull(): null|array
302310
}
303311

304312
throw ExtractorException::becauseDataDidNotMatchExpectedType(
305-
path: $this->path,
313+
path: $this->pathUntilFirstNullEncounter,
306314
expectedType: 'array or null',
307315
attemptedData: $this->data,
308316
isKey: $this->isKey
@@ -327,14 +335,29 @@ public function offsetExists(mixed $offset): bool
327335
public function offsetGet(mixed $offset): mixed
328336
{
329337
if ($this->data === null) {
330-
return new self(null, [...$this->path, $offset], false);
338+
return new self(
339+
data: null,
340+
pathUntilFirstNullEncounter: $this->pathUntilFirstNullEncounter,
341+
entireAccessPath: [...$this->entireAccessPath, $offset],
342+
isKey: false
343+
);
331344
}
332345

333346
$data = $this->array();
334347

335348
return array_key_exists($offset, $data)
336-
? new self($data[$offset], [...$this->path, $offset], false)
337-
: new self(null, [...$this->path, $offset], false);
349+
? new self(
350+
data: $data[$offset],
351+
pathUntilFirstNullEncounter: [...$this->entireAccessPath, $offset],
352+
entireAccessPath: [...$this->entireAccessPath, $offset],
353+
isKey: false
354+
)
355+
: new self(
356+
data: null,
357+
pathUntilFirstNullEncounter: [...$this->entireAccessPath, $offset],
358+
entireAccessPath: [...$this->entireAccessPath, $offset],
359+
isKey: false
360+
);
338361
}
339362

340363
/**
@@ -378,7 +401,12 @@ public function getIterator(): \Traversable
378401
{
379402
if ($this->data !== null) {
380403
foreach ($this->array() as $key => $value) {
381-
yield $this->forKey($key) => new self($value, [...$this->path, $key], false);
404+
yield $this->forKey($key) => new self(
405+
data: $value,
406+
pathUntilFirstNullEncounter: [...$this->entireAccessPath, $key],
407+
entireAccessPath: [...$this->entireAccessPath, $key],
408+
isKey: false
409+
);
382410
}
383411
}
384412
}

src/ExtractorException.php

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,18 @@ final class ExtractorException extends \Exception
3232
{
3333
/**
3434
* @param (int|string)[] $path
35-
* @param string $message
35+
* @param string $rawMessage
3636
*/
3737
private function __construct(
3838
private readonly array $path,
39-
string $message
39+
private readonly string $rawMessage
4040
) {
41-
parent::__construct($message, 1669042598);
41+
parent::__construct(
42+
$path
43+
? sprintf('Extraction failed at path "%s": %s', implode('.', $path), $rawMessage)
44+
: sprintf('Extraction failed: %s', $rawMessage),
45+
1669042598
46+
);
4247
}
4348

4449
/**
@@ -49,13 +54,24 @@ public function getPath(): array
4954
return $this->path;
5055
}
5156

57+
/**
58+
* @return string
59+
*/
60+
public function getRawMessage(): string
61+
{
62+
return $this->rawMessage;
63+
}
64+
5265
/**
5366
* @param (int|string)[] $path
5467
* @return self
5568
*/
5669
public static function becauseDataIsRequiredButNullWasPassed(array $path): self
5770
{
58-
return new self($path, 'Value is required, but was null.');
71+
return new self(
72+
path: $path,
73+
rawMessage: 'Value is required, but was null.'
74+
);
5975
}
6076

6177
/**
@@ -72,7 +88,7 @@ public static function becauseDataDidNotMatchExpectedType(
7288
): self {
7389
return new self(
7490
path: $path,
75-
message: sprintf(
91+
rawMessage: sprintf(
7692
'%s was expected to be of type %s, got %s instead.',
7793
$isKey ? 'Key' : 'Value',
7894
$expectedType,

tests/Unit/ExtractorArrayAccessTest.php

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,8 @@ public function keepsTrackOfPathWhenExtractorExceptionHappensAtADeeperLevel(): v
201201
$this->expectException(ExtractorException::class);
202202

203203
try {
204-
$extractor = Extractor::for([]);
205-
$extractor['foo']['bar']['baz']->array();
204+
$extractor = Extractor::for(['foo' => ['bar' => ['baz' => 42]]]);
205+
$extractor['foo']['bar']['baz']->string();
206206
} catch (ExtractorException $e) {
207207
$this->assertEquals(
208208
['foo', 'bar', 'baz'],
@@ -213,6 +213,27 @@ public function keepsTrackOfPathWhenExtractorExceptionHappensAtADeeperLevel(): v
213213
}
214214
}
215215

216+
/**
217+
* @test
218+
* @return void
219+
*/
220+
public function onlyKeepsTrackOfPathUntilFirstNullValueWasEncountered(): void
221+
{
222+
$this->expectException(ExtractorException::class);
223+
224+
try {
225+
$extractor = Extractor::for(['foo' => ['bar' => null]]);
226+
$extractor['foo']['bar']['baz']->array();
227+
} catch (ExtractorException $e) {
228+
$this->assertEquals(
229+
['foo', 'bar'],
230+
$e->getPath()
231+
);
232+
233+
throw $e;
234+
}
235+
}
236+
216237
/**
217238
* @test
218239
* @return void

0 commit comments

Comments
 (0)