Skip to content

Commit bc70e4b

Browse files
committed
[core] v4.3.1 - See CHANGELOG.md for details
1 parent 15e5d26 commit bc70e4b

6 files changed

Lines changed: 91 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# CHANGELOG
22

3+
## [v4.3.1] - 2026-05-04
4+
5+
### Fixed
6+
7+
- **`fromJson()` list-rooted JSON rejection now matches the docblock contract.** Inputs that, after `trim`, begin with `[` and are not exactly `[]` throw `InvalidJsonException` at the start of `fromJson()`. Previously such inputs failed indirectly via downstream `RequiredValueException` during property resolution; catch sites that depended on the old exception type must be updated. The empty literal `[]` is intentionally permitted to preserve `toJson()``fromJson()` round-trips when `#[SkipOnNull]` empties an associative DTO (PHP's `json_encode([])` cannot disambiguate empty object from empty array).
8+
- **Sub-DTO JSON-string parsing symmetry.** Construction-path sub-DTO properties already accepted JSON-like strings via `buildResolver()`; the union dispatch path (`valueDecide()`) now applies the same `is_string && jsonLike => fromJson()` rule, eliminating the inconsistency where `Foo $foo` accepted `'{...}'` but `Foo|Bar $foo` rejected the same input.
9+
310
## [v4.3.0] - 2026-04-19
411

512
### Added

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,7 @@ Thrown during construction (`fromArray`, `fromJson`) or mutation (`with`) when i
755755

756756
`InvalidEnumValueException` - The value cannot be resolved to any case of the target Enum; both name lookup and `tryFrom()` failed.
757757

758-
`InvalidJsonException` - JSON string decoding failed.
758+
`InvalidJsonException` - The JSON string cannot be parsed into an object: malformed JSON, or non-empty list-rooted JSON (e.g. `[1,2,3]`). The empty literal `[]` is permitted to preserve `toJson()``fromJson()` round-trips when `#[SkipOnNull]` empties an associative DTO.
759759

760760
#### ValidationException - Validation Errors
761761

README_TW.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,7 +754,7 @@ vendor/bin/ib-writer
754754

755755
`InvalidEnumValueException` - 值無法解析為目標 Enum 的任何 case,名稱查找及 `tryFrom()` 皆失敗。
756756

757-
`InvalidJsonException` - JSON 字串解碼失敗
757+
`InvalidJsonException` - JSON 字串無法解析為物件:JSON 格式錯誤,或為非空 list-rooted JSON(如 `[1,2,3]`)。空字面 `[]` 為例外允許,以保留 `#[SkipOnNull]` 將關聯式 DTO 清空後的 `toJson()``fromJson()` round-trip
758758

759759
#### ValidationException - 驗證錯誤
760760

src/ImmutableBase.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -611,10 +611,11 @@ private static function buildResolver(mixed $type, bool $isSub, bool $isSVO): ca
611611
$type['isUnion'] => static fn(mixed $value) => self::unionTypeDecide($type, $value),
612612
!$type['isBuiltin'] => match (true) {
613613
$isSub => static fn(mixed $value): mixed => match (true) {
614-
\is_array($value) => $typename::fromArray($value),
615-
$value instanceof $typename => $value,
616-
$isSVO => $typename::from($value),
617-
default => throw new InvalidValueException($typename, $value)
614+
\is_array($value) => $typename::fromArray($value),
615+
$value instanceof $typename => $value,
616+
$isSVO => $typename::from($value),
617+
\is_string($value) && self::jsonLike($value) => $typename::fromJson($value),
618+
default => throw new InvalidValueException($typename, $value)
618619
},
619620
default => static fn(mixed $value): mixed => match (true) {
620621
$value instanceof $typename => $value,
@@ -700,6 +701,7 @@ private static function valueDecide(array $type, mixed $value): mixed
700701
(\is_string($value) || \is_int($value)) && $type['isEnum'] => self::analyzeEnum($typename, $value),
701702
\is_array($value) && is_a($typename, self::class, true) => $typename::fromArray($value),
702703
is_a($typename, SingleValueObject::class, true) && !\is_object($value) => $typename::from($value),
704+
\is_string($value) && self::jsonLike($value) && is_a($typename, self::class, true) => $typename::fromJson($value),
703705
default => throw new InvalidValueException($typename, $value),
704706
};
705707
}
@@ -1073,6 +1075,11 @@ final public static function fromArray(array $array): static
10731075
*/
10741076
final public static function fromJson(string $data): static
10751077
{
1078+
$trimmed = trim($data);
1079+
if (($trimmed[0] ?? '') === '[' && $trimmed !== '[]') {
1080+
throw new InvalidJsonException();
1081+
}
1082+
10761083
return new static(self::jsonParser($data, false));
10771084
}
10781085

tests/Attacks/AttackTest.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,10 +448,22 @@ public function testFromArrayEmptyWithRequiredProperties(): void
448448

449449
public function testFromJsonWithArrayJsonThrows(): void
450450
{
451-
$this->expectException(RequiredValueException::class);
451+
$this->expectException(InvalidJsonException::class);
452452
AddressDTO::fromJson('[1,2,3]');
453453
}
454454

455+
public function testFromJsonWithSingleElementListJsonThrows(): void
456+
{
457+
$this->expectException(InvalidJsonException::class);
458+
AddressDTO::fromJson('[1]');
459+
}
460+
461+
public function testFromJsonWithLeadingWhitespaceListJsonThrows(): void
462+
{
463+
$this->expectException(InvalidJsonException::class);
464+
AddressDTO::fromJson(' [1,2,3]');
465+
}
466+
455467
public function testDeepRoundTrip(): void
456468
{
457469
$data = [

tests/DefaultTest.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,64 @@ public function testInvalidArrayOfNativeBoolException()
559559
ArrayOfDTO::fromArray(array_merge($this->arrayOfData, ['floats' => [1]]));
560560
}
561561

562+
public function testFromArrayWithJsonStringSubDtoSucceeds()
563+
{
564+
$extra = ExtraDTO::fromArray([
565+
'string2' => 'string2',
566+
'dto' => $this->json,
567+
'unionClasses2' => SVO::from('x'),
568+
]);
569+
$this->assertEquals(
570+
DTO::fromArray($this->array)->toArray(),
571+
$extra->dto->toArray(),
572+
);
573+
}
574+
575+
public function testFromArrayWithLeadingWhitespaceJsonStringSubDtoSucceeds()
576+
{
577+
$extra = ExtraDTO::fromArray([
578+
'string2' => 'string2',
579+
'dto' => ' ' . $this->json,
580+
'unionClasses2' => SVO::from('x'),
581+
]);
582+
$this->assertEquals(
583+
DTO::fromArray($this->array)->toArray(),
584+
$extra->dto->toArray(),
585+
);
586+
}
587+
588+
public function testFromArrayWithListRootedJsonStringSubDtoThrows()
589+
{
590+
$this->expectException(InvalidJsonException::class);
591+
ExtraDTO::fromArray([
592+
'string2' => 'string2',
593+
'dto' => '[1,2,3]',
594+
'unionClasses2' => SVO::from('x'),
595+
]);
596+
}
597+
598+
public function testFromArrayWithMalformedJsonStringSubDtoThrows()
599+
{
600+
$this->expectException(InvalidJsonException::class);
601+
ExtraDTO::fromArray([
602+
'string2' => 'string2',
603+
'dto' => '{garbage',
604+
'unionClasses2' => SVO::from('x'),
605+
]);
606+
}
607+
608+
public function testFromArrayWithJsonStringForUnionSubDtoSucceeds()
609+
{
610+
$union = UnionWithImmutableBaseTypeDTO::fromArray([
611+
'mixed' => $this->json,
612+
]);
613+
$this->assertInstanceOf(DTO::class, $union->mixed);
614+
$this->assertEquals(
615+
DTO::fromArray($this->array)->toArray(),
616+
$union->mixed->toArray(),
617+
);
618+
}
619+
562620
private static function attributeCoverage(string $class, mixed $argument)
563621
{
564622
$ref = new ReflectionClass($class);

0 commit comments

Comments
 (0)