Skip to content

Commit ebcc123

Browse files
authored
feat(type): add json_decoded() type for transparent JSON string coercion (#619)
1 parent ca8d05d commit ebcc123

File tree

6 files changed

+289
-0
lines changed

6 files changed

+289
-0
lines changed

docs/content/types/type.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ When conversion fails, error messages indicate which stage failed:
106106
107107
> Could not coerce "string" to type "class-string<stdClass>" **at path "coerce_output(string): class-string<stdClass>"**
108108
109+
## JSON Decoding with `json_decoded()`
110+
111+
The `json_decoded()` type transparently handles fields that may contain JSON-encoded strings. If the value already matches the inner type, it passes through; if it's a string, it's JSON-decoded and coerced through the inner type. This is especially useful in shapes where database columns store JSON:
112+
113+
@example('types/type-json-decoded.php')
114+
109115
## Strict Mode with `always_assert()`
110116

111117
By default, `coerce()` attempts type conversion. Use `always_assert()` to create a type that rejects any value not already matching, even during coercion:
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
require_once __DIR__ . '/../../../vendor/autoload.php';
6+
7+
use Psl\Type;
8+
9+
$shape = Type\shape([
10+
'name' => Type\string(),
11+
'metadata' => Type\json_decoded(Type\shape([
12+
'role' => Type\string(),
13+
'active' => Type\bool(),
14+
])),
15+
]);
16+
17+
// Works with JSON strings (e.g. from a database column)
18+
$fromDb = $shape->coerce([
19+
'name' => 'Alice',
20+
'metadata' => '{"role": "admin", "active": true}',
21+
]);
22+
23+
// Also works with already-decoded arrays
24+
$fromArray = $shape->coerce([
25+
'name' => 'Alice',
26+
'metadata' => ['role' => 'admin', 'active' => true],
27+
]);

src/Psl/Internal/Loader.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,7 @@ final class Loader
516516
'Psl\\Type\\int_range' => 'Psl/Type/int_range.php',
517517
'Psl\\Type\\int' => 'Psl/Type/int.php',
518518
'Psl\\Type\\intersection' => 'Psl/Type/intersection.php',
519+
'Psl\\Type\\json_decoded' => 'Psl/Type/json_decoded.php',
519520
'Psl\\Type\\iterable' => 'Psl/Type/iterable.php',
520521
'Psl\\Type\\always_assert' => 'Psl/Type/always_assert.php',
521522
'Psl\\Type\\container' => 'Psl/Type/container.php',
@@ -992,6 +993,7 @@ final class Loader
992993
'Psl\\Type\\Internal\\ConvertedType' => 'Psl/Type/Internal/ConvertedType.php',
993994
'Psl\\Type\\Internal\\FloatType' => 'Psl/Type/Internal/FloatType.php',
994995
'Psl\\Type\\Internal\\IntersectionType' => 'Psl/Type/Internal/IntersectionType.php',
996+
'Psl\\Type\\Internal\\JsonDecodedType' => 'Psl/Type/Internal/JsonDecodedType.php',
995997
'Psl\\Type\\Internal\\IntType' => 'Psl/Type/Internal/IntType.php',
996998
'Psl\\Type\\Internal\\IterableType' => 'Psl/Type/Internal/IterableType.php',
997999
'Psl\\Type\\Internal\\MixedType' => 'Psl/Type/Internal/MixedType.php',
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Psl\Type\Internal;
6+
7+
use Override;
8+
use Psl\Json;
9+
use Psl\Type;
10+
use Psl\Type\Exception\AssertException;
11+
use Psl\Type\Exception\CoercionException;
12+
use Throwable;
13+
14+
/**
15+
* @template T
16+
*
17+
* @extends Type\Type<T>
18+
*
19+
* @internal
20+
*/
21+
final readonly class JsonDecodedType extends Type\Type
22+
{
23+
/**
24+
* @psalm-mutation-free
25+
*
26+
* @param Type\TypeInterface<T> $inner
27+
*/
28+
public function __construct(
29+
private Type\TypeInterface $inner,
30+
) {}
31+
32+
/**
33+
* @throws CoercionException
34+
*
35+
* @return T
36+
*/
37+
#[Override]
38+
public function coerce(mixed $value): mixed
39+
{
40+
if ($this->inner->matches($value)) {
41+
return $value;
42+
}
43+
44+
if (!is_string($value)) {
45+
throw CoercionException::withValue($value, $this->toString());
46+
}
47+
48+
try {
49+
/** @var mixed $decoded */
50+
$decoded = Json\decode($value);
51+
} catch (Json\Exception\DecodeException $e) {
52+
throw CoercionException::withValue(
53+
$value,
54+
$this->toString(),
55+
PathExpression::coerceInput($value, $this->inner->toString()),
56+
$e,
57+
);
58+
}
59+
60+
try {
61+
return $this->inner->coerce($decoded);
62+
} catch (Throwable $e) {
63+
throw CoercionException::withValue(
64+
$value,
65+
$this->toString(),
66+
PathExpression::coerceOutput($decoded, $this->inner->toString()),
67+
$e,
68+
);
69+
}
70+
}
71+
72+
/**
73+
* @throws AssertException
74+
*
75+
* @return T
76+
*
77+
* @psalm-assert T $value
78+
*/
79+
#[Override]
80+
public function assert(mixed $value): mixed
81+
{
82+
return $this->inner->assert($value);
83+
}
84+
85+
#[Override]
86+
public function toString(): string
87+
{
88+
return 'json-decoded<' . $this->inner->toString() . '>';
89+
}
90+
}

src/Psl/Type/json_decoded.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Psl\Type;
6+
7+
/**
8+
* @pure
9+
*
10+
* @template T
11+
*
12+
* @param TypeInterface<T> $inner_type
13+
*
14+
* @return TypeInterface<T>
15+
*/
16+
function json_decoded(TypeInterface $inner_type): TypeInterface
17+
{
18+
return new Internal\JsonDecodedType($inner_type);
19+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Psl\Tests\Unit\Type;
6+
7+
use Override;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use Psl\Str;
10+
use Psl\Type;
11+
12+
final class JsonDecodedTypeTest extends TypeTestCase
13+
{
14+
#[Override]
15+
public static function getType(): Type\TypeInterface
16+
{
17+
return Type\json_decoded(Type\shape([
18+
'name' => Type\string(),
19+
'age' => Type\int(),
20+
]));
21+
}
22+
23+
#[Override]
24+
public static function getValidCoercions(): iterable
25+
{
26+
yield [
27+
'{"name": "Alice", "age": 30}',
28+
['name' => 'Alice', 'age' => 30],
29+
];
30+
yield [
31+
'{"name": "Bob", "age": 25}',
32+
['name' => 'Bob', 'age' => 25],
33+
];
34+
// Already-decoded value passes through
35+
yield [
36+
['name' => 'Charlie', 'age' => 40],
37+
['name' => 'Charlie', 'age' => 40],
38+
];
39+
}
40+
41+
#[Override]
42+
public static function getInvalidCoercions(): iterable
43+
{
44+
yield [1];
45+
yield [false];
46+
yield [null];
47+
yield ['not json'];
48+
yield ['{"name": "Alice"}']; // missing 'age'
49+
yield ['{invalid}'];
50+
}
51+
52+
#[Override]
53+
public static function getToStringExamples(): iterable
54+
{
55+
yield [static::getType(), "json-decoded<array{'name': string, 'age': int}>"];
56+
}
57+
58+
public static function provideCoerceExceptionExpectations(): iterable
59+
{
60+
yield 'non-string input' => [
61+
Type\json_decoded(Type\dict(Type\string(), Type\mixed())),
62+
42,
63+
'Could not coerce "int" to type "json-decoded<dict<string, mixed>>".',
64+
];
65+
yield 'invalid json' => [
66+
Type\json_decoded(Type\dict(Type\string(), Type\mixed())),
67+
'{invalid}',
68+
'Could not coerce "string" to type "json-decoded<dict<string, mixed>>" at path "coerce_input(string): dict<string, mixed>": Syntax error..',
69+
];
70+
yield 'decoded value does not match inner type' => [
71+
Type\json_decoded(Type\vec(Type\int())),
72+
'{"key": "value"}',
73+
'Could not coerce "string" to type "json-decoded<vec<int>>" at path "coerce_output(array): vec<int>.key".',
74+
];
75+
}
76+
77+
#[DataProvider('provideCoerceExceptionExpectations')]
78+
public function testInvalidCoercionTypeExceptions(
79+
Type\TypeInterface $type,
80+
mixed $data,
81+
string $expectedMessage,
82+
): void {
83+
try {
84+
$type->coerce($data);
85+
static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class));
86+
} catch (Type\Exception\CoercionException $e) {
87+
static::assertSame($expectedMessage, $e->getMessage());
88+
}
89+
}
90+
91+
public function testCoercesJsonStringInShape(): void
92+
{
93+
$shape = Type\shape([
94+
'name' => Type\string(),
95+
'metadata' => Type\json_decoded(Type\shape([
96+
'role' => Type\string(),
97+
'active' => Type\bool(),
98+
])),
99+
]);
100+
101+
$result = $shape->coerce([
102+
'name' => 'Alice',
103+
'metadata' => '{"role": "admin", "active": true}',
104+
]);
105+
106+
static::assertSame(
107+
[
108+
'name' => 'Alice',
109+
'metadata' => ['role' => 'admin', 'active' => true],
110+
],
111+
$result,
112+
);
113+
}
114+
115+
public function testPassesThroughAlreadyDecodedValueInShape(): void
116+
{
117+
$shape = Type\shape([
118+
'name' => Type\string(),
119+
'metadata' => Type\json_decoded(Type\shape([
120+
'role' => Type\string(),
121+
])),
122+
]);
123+
124+
$result = $shape->coerce([
125+
'name' => 'Alice',
126+
'metadata' => ['role' => 'admin'],
127+
]);
128+
129+
static::assertSame(
130+
[
131+
'name' => 'Alice',
132+
'metadata' => ['role' => 'admin'],
133+
],
134+
$result,
135+
);
136+
}
137+
138+
public function testWithSimpleType(): void
139+
{
140+
$type = Type\json_decoded(Type\int());
141+
142+
static::assertSame(42, $type->coerce('42'));
143+
static::assertSame(42, $type->coerce(42));
144+
}
145+
}

0 commit comments

Comments
 (0)