Skip to content

Commit 62535bb

Browse files
authored
GH-75: Document and cover the defensive guards, rather than delete them (#121)
* GH-75: Document the defensive guards the issue called dead, and cover four The issue lists five "dead defensive code paths" to remove. Measured, none is dead - each is a guard that is unreachable through the value converter's CHAIN but live for another entry, and removing them would drop real protection: - The two null guards (builtin, object trait) throw for a null on a non-nullable target. Through the chain NullValueConversionStrategy claims every null first, so they never run there - but the strategy classes are public SPI, and a direct convert() call reaches them. Proven: a direct builtin convert(null, int) throws TypeMismatchException, as it should. - The convertObjectValue non-object branch hands the value back for a type its own supports() would reject - reached only by a direct call skipping supports(). Proven the same way. - The ValueConverter LogicException is unreachable while the passthrough strategy (supports() always true, last) is registered, and guards the invariant if that ever changes - otherwise the method would fall off the end returning null. - The "collection class must be provided" throw is not a runtime guard at all but a load-bearing assertion: it narrows ?string to string for the calls below, and PHPStan max fails without it. Removing it, verified, breaks static analysis. So the resolution is the acceptance criterion's "documented reason", not removal: each branch now says why it cannot occur on the normal path and why it is kept. The four SPI/invariant guards also gain tests that drive them directly, turning "documented dead branch" into "live, covered branch" - the criterion's preferred outcome for the null guards. The architecture audit on the issue asked that the null guards not be deleted before the two-entry-points fix (#87, still open), since a single entry point may make them live through the chain too. Documenting and covering rather than deleting respects that: nothing here has to be revisited when #87 lands, and if #87 makes a guard live on the chain, its test simply gains a second caller. * GH-75: Cover the object null guard, drop the misuse-contract test, pin the message Review found my commit message claimed to cover four guards but only three tests existed - and the missing one was exactly the object-trait null guard. Removing its throw made convertObjectValue return null for a non-nullable object target with the whole suite still green, the precise regression this was meant to prevent. Added theObjectGuardRefusesANull..., verified by mutation: neutralising the throw now fails it. Dropped theObjectGuardHandsBackAValueForANonObjectType. It asserted the exact silent-passthrough the object trait does for a type it does not support - but the SPI interface promises nothing about convert() on an unsupported type, so the test froze a misuse-only implementation detail as contract, and a future fail-fast refactor would break it for no real reason. The branch keeps its documented reason; it is defensive against a protocol violation, which the acceptance criterion covers by documentation rather than a test. And the builtin guard test asserted only the exception type, which several sites in that strategy throw. It now matches the message, so it pins this guard rather than any TypeMismatchException.
1 parent 976e49a commit 62535bb

5 files changed

Lines changed: 113 additions & 0 deletions

File tree

src/JsonMapper.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,11 @@ private function mapCollection(
483483
$isGenericCollectionMapping = $resolvedClassName === null && $collectionValueType instanceof Type;
484484

485485
if ($isGenericCollectionMapping) {
486+
// Not reachable at runtime: extractCollectionType() returns a Type only when the
487+
// collection class is set, so $collectionValueType being one already implies a non-null
488+
// collection class here. Kept because it also narrows ?string to string for the calls
489+
// below - PHPStan max fails without it - so it is a load-bearing assertion, not merely
490+
// a runtime guard.
486491
if ($resolvedCollectionClassName === null) {
487492
throw new InvalidArgumentException(
488493
'A collection class name must be provided when mapping without an element class.'

src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,11 @@ private function guardCompatibility(mixed $value, BuiltinType $type, MappingCont
245245
return;
246246
}
247247

248+
// Reached only by a DIRECT call to convert() on this strategy. Through the value
249+
// converter's chain a null never arrives here, because NullValueConversionStrategy is
250+
// registered first and claims every null - but the strategy classes are public SPI, so
251+
// this defends the case where one is invoked outside the chain.
252+
//
248253
// Throw rather than record-and-continue: recording it here and returning anyway
249254
// leaves the caller to store a null the declared type forbids. On a property that
250255
// means a value contradicting its own docblock; in a collection it means the offending

src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ private function guardNullableValue(mixed $value, ObjectType $type, MappingConte
6060
return;
6161
}
6262

63+
// Reached only by a DIRECT call to a strategy using this trait. Through the value
64+
// converter's chain a null is claimed by NullValueConversionStrategy first, so it never
65+
// arrives; the guard defends the public-SPI case where a strategy is invoked outside the
66+
// chain, keeping a null off a non-nullable object target.
6367
throw new TypeMismatchException($context->getPath(), $type->getClassName(), 'null');
6468
}
6569

@@ -77,6 +81,9 @@ private function convertObjectValue(Type $type, MappingContext $context, mixed $
7781
{
7882
$objectType = $this->extractObjectType($type);
7983

84+
// Unreachable through the chain: supports() returns false for a non-object type, so convert()
85+
// is not called for one. Kept for a DIRECT call that skips supports() - it hands the value
86+
// back untouched rather than dereferencing a null object type.
8087
if ($objectType === null) {
8188
return $value;
8289
}

src/JsonMapper/Value/ValueConverter.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ public function convert(Type $type, mixed $value, MappingContext $context): mixe
5757
}
5858
}
5959

60+
// Unreachable while PassthroughValueConversionStrategy is registered: its supports()
61+
// always returns true and it is added last, so the loop always finds a strategy. Kept as an
62+
// invariant guard - a future change that removed or misordered the passthrough would
63+
// otherwise fall off the end of this method returning null implicitly.
6064
throw new LogicException(
6165
sprintf('No conversion strategy available for type %s.', $type::class)
6266
);
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
/**
4+
* This file is part of the package magicsunday/jsonmapper.
5+
*
6+
* For the full copyright and license information, please read the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
declare(strict_types=1);
11+
12+
namespace MagicSunday\Test\JsonMapper\Value;
13+
14+
use DateTime;
15+
use LogicException;
16+
use MagicSunday\JsonMapper\Context\MappingContext;
17+
use MagicSunday\JsonMapper\Exception\TypeMismatchException;
18+
use MagicSunday\JsonMapper\Value\Strategy\BuiltinValueConversionStrategy;
19+
use MagicSunday\JsonMapper\Value\Strategy\DateTimeValueConversionStrategy;
20+
use MagicSunday\JsonMapper\Value\Strategy\ValueConversionStrategyInterface;
21+
use MagicSunday\JsonMapper\Value\ValueConverter;
22+
use PHPUnit\Framework\Attributes\Test;
23+
use PHPUnit\Framework\TestCase;
24+
use Symfony\Component\TypeInfo\Type;
25+
use Symfony\Component\TypeInfo\Type\BuiltinType;
26+
use Symfony\Component\TypeInfo\Type\ObjectType;
27+
use Symfony\Component\TypeInfo\TypeIdentifier;
28+
29+
/**
30+
* The strategy classes are public SPI: a caller may register its own and, in doing so, may invoke a
31+
* strategy directly rather than through the value converter's chain. Several guards inside them are
32+
* unreachable through that chain - NullValueConversionStrategy claims every null before they run -
33+
* but not dead: they defend exactly the direct-invocation case. These pin that they still hold, so
34+
* a future "this branch is never hit" cleanup meets a red test rather than a silent contract loss.
35+
*
36+
* @internal
37+
*/
38+
final class StrategyDirectInvocationTest extends TestCase
39+
{
40+
#[Test]
41+
public function theBuiltinStrategyRefusesANullForANonNullableType(): void
42+
{
43+
$strategy = new BuiltinValueConversionStrategy();
44+
45+
// The message pins THIS guard: TypeMismatchException is thrown from several sites in the
46+
// strategy, and a future refactor routing null down a different one would otherwise keep
47+
// this test green while covering the wrong branch. Matches() not Message() - the latter is
48+
// deprecated across the PHPUnit majors the constraint spans.
49+
$this->expectException(TypeMismatchException::class);
50+
$this->expectExceptionMessageMatches('/expected int, got null/');
51+
52+
$strategy->convert(new BuiltinType(TypeIdentifier::INT), null, new MappingContext([]));
53+
}
54+
55+
#[Test]
56+
public function theObjectGuardRefusesANullForANonNullableType(): void
57+
{
58+
// The object-trait counterpart of the builtin guard above, and the one the first round of
59+
// tests missed. Through the chain a null is claimed by NullValueConversionStrategy first;
60+
// a direct call reaches guardNullableValue, which must keep a null off a non-nullable
61+
// object target rather than let convertObjectValue return it.
62+
$strategy = new DateTimeValueConversionStrategy();
63+
64+
$this->expectException(TypeMismatchException::class);
65+
66+
$strategy->convert(new ObjectType(DateTime::class), null, new MappingContext([]));
67+
}
68+
69+
#[Test]
70+
public function theConverterRaisesWhenNoStrategyMatches(): void
71+
{
72+
// The invariant guard: with no passthrough registered and a type nothing supports, the loop
73+
// finds no strategy. In production PassthroughValueConversionStrategy (supports() always
74+
// true, registered last) makes this unreachable - this drives it directly.
75+
$converter = new ValueConverter();
76+
$converter->addStrategy(new class implements ValueConversionStrategyInterface {
77+
public function supports(Type $type, mixed $value, MappingContext $context): bool
78+
{
79+
return false;
80+
}
81+
82+
public function convert(Type $type, mixed $value, MappingContext $context): mixed
83+
{
84+
return $value;
85+
}
86+
});
87+
88+
$this->expectException(LogicException::class);
89+
90+
$converter->convert(new BuiltinType(TypeIdentifier::INT), 'x', new MappingContext([]));
91+
}
92+
}

0 commit comments

Comments
 (0)