Skip to content
Merged
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"require": {
"php": ">=8.4",
"ext-json": "*",
"tcds-io/php-better-generics": "^0.1.0"
"tcds-io/php-better-generics": "^1.0"
},
"require-dev": {
"symfony/var-dumper": "^6.0",
Expand Down
66 changes: 57 additions & 9 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 61 additions & 11 deletions src/ArrayObjectMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

namespace Tcds\Io\Jackson;

use Closure;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Override;
use ReflectionAttribute;
use ReflectionClass;
use Tcds\Io\Generic\Reflection\ReflectionFunction;
use Tcds\Io\Generic\Reflection\Type\Parser\TypeParser;
use Tcds\Io\Generic\Reflection\Type\Parser\DocBlockTypeResolver;
use Tcds\Io\Jackson\Exception\JacksonException;
use Tcds\Io\Jackson\Node\JsonMapper;
use Tcds\Io\Jackson\Node\Mappers\Readers\DateTimeReader;
use Tcds\Io\Jackson\Node\Mappers\Writers\DateTimeWriter;
use Tcds\Io\Jackson\Node\Reader;
Expand Down Expand Up @@ -67,7 +71,8 @@ public function __construct(
];
}

#[Override] public function readValueWith(string $type, mixed $value, array $with = [])
#[Override]
public function readValueWith(string $type, mixed $value, array $with = [])
{
/** @var array<mixed> $value */
return $this->readValue($type, [
Expand All @@ -76,14 +81,14 @@ public function __construct(
]);
}

#[Override] public function readValue(string $type, mixed $value, array $path = []): mixed
#[Override]
public function readValue(string $type, mixed $value, array $path = []): mixed
{
[$main] = TypeParser::getGenericTypes($type);
$reader = $this->typeMappers[$main]['reader'] ?? $this->defaultTypeReader;
$callable = $reader instanceof StaticReader ? $reader::read(...) : $reader;
[$main] = DocBlockTypeResolver::instance()->genericTypeParts($type);
$callable = $this->resolveReader($main);

try {
return ReflectionFunction::call($callable(...), [
return ReflectionFunction::call($callable, [
'data' => $value,
'type' => $type,
'mapper' => $this,
Expand All @@ -100,12 +105,11 @@ public function __construct(
public function writeValue(mixed $value, ?string $type = null, array $path = []): mixed
{
$type ??= TypeNode::of($value);
[$main] = TypeParser::getGenericTypes($type);
$writer = $this->typeMappers[$main]['writer'] ?? $this->defaultTypeWriter;
$callable = $writer instanceof StaticWriter ? $writer::write(...) : $writer;
[$main] = DocBlockTypeResolver::instance()->genericTypeParts($type);
$callable = $this->resolveWriter($main);

try {
return ReflectionFunction::call($callable(...), [
return ReflectionFunction::call($callable, [
'data' => $value,
'type' => $type,
'mapper' => $this,
Expand All @@ -117,4 +121,50 @@ public function writeValue(mixed $value, ?string $type = null, array $path = [])
throw new JacksonException('Failed to write value', path: $path, previous: $e);
}
}

private function resolveReader(string $main): Closure
{
$reader = $this->classAttribute($main)->reader
?? $this->typeMappers[$main]['reader']
?? $this->defaultTypeReader;

/** @var Callable $callable */
$callable = ($reader instanceof StaticReader || is_subclass_of($reader, StaticReader::class))
? $reader::read(...)
: $reader;

return $callable(...);
}

private function resolveWriter(string $main): Closure
{
$writer = $this->classAttribute($main)->writer
?? $this->typeMappers[$main]['writer']
?? $this->defaultTypeWriter;

/** @var Callable $callable */
$callable = ($writer instanceof StaticWriter || is_subclass_of($writer, StaticWriter::class))
? $writer::write(...)
: $writer;

return $callable(...);
}

/**
* @param string $type
*/
private function classAttribute(string $type): ?JsonMapper
{
if (!class_exists($type)) {
return null;
}

/** @var array{ 0?: ReflectionAttribute<JsonMapper> } $attributes */
$attributes = new ReflectionClass($type)->getAttributes(JsonMapper::class);

/** */
return $attributes === []
? null
: $attributes[0]->newInstance();
}
}
43 changes: 43 additions & 0 deletions src/Node/JsonMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Tcds\Io\Jackson\Node;

use Attribute;
use Closure;
use Tcds\Io\Jackson\ObjectMapper;

/**
* Class-level attribute that pins a custom reader and/or writer to a class.
*
* Both arguments take a class string. Accepted shapes:
* - implementations of `Reader`/`Writer` (instance interface)
* - implementations of `StaticReader`/`StaticWriter` (no instantiation)
* - any class with an `__invoke` matching `MapperClosure` from
* {@see ObjectMapper} (a reader/writer "in closure form")
*
* The class is instantiated with a no-arg constructor when an instance is
* needed. PHP attributes cannot carry literal closures — use the
* `typeMappers` array on `ArrayObjectMapper` for those.
*
* Resolution order on every call:
* 1. `#[JsonMapper]` attribute on the target class (declaration site, wins)
* 2. `typeMappers` constructor argument
* 3. default reader/writer
*
* @phpstan-import-type MapperClosure from ObjectMapper
*/
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class JsonMapper
{
/**
* @param Reader<mixed>|Closure|class-string<StaticReader<mixed>>|null $reader
* @param Writer<mixed>|Closure|class-string<StaticWriter<mixed>>|null $writer
*/
public function __construct(
public Reader|Closure|string|null $reader = null,
public Writer|Closure|string|null $writer = null,
) {
}
}
4 changes: 2 additions & 2 deletions src/Node/Runtime/RuntimeReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use BackedEnum;
use Override;
use Tcds\Io\Generic\Reflection\Type\Parser\TypeParser;
use Tcds\Io\Generic\Reflection\Type\Parser\DocBlockTypeResolver;
use Tcds\Io\Generic\Reflection\Type\ReflectionType;
use Tcds\Io\Jackson\Exception\JacksonException;
use Tcds\Io\Jackson\Exception\UnableToParseValue;
Expand Down Expand Up @@ -98,7 +98,7 @@ private function readEnum(string $enum, mixed $value, array $path): BackedEnum
private function readClass(ObjectMapper $mapper, TypeNode $node, mixed $data, array $path): mixed
{
$values = $this->readValues($mapper, $node, $data, $path);
[$class] = TypeParser::getGenericTypes($node->type);
[$class] = DocBlockTypeResolver::instance()->genericTypeParts($node->type);

try {
return new $class(...$values);
Expand Down
8 changes: 4 additions & 4 deletions src/Node/Runtime/RuntimeTypeNodeFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use Tcds\Io\Generic\Reflection\ReflectionClass;
use Tcds\Io\Generic\Reflection\ReflectionMethodParameter;
use Tcds\Io\Generic\Reflection\ReflectionProperty;
use Tcds\Io\Generic\Reflection\Type\Parser\TypeParser;
use Tcds\Io\Generic\Reflection\Type\Parser\DocBlockTypeResolver;
use Tcds\Io\Generic\Reflection\Type\ReflectionType;
use Tcds\Io\Jackson\Node\InputNode;
use Tcds\Io\Jackson\Node\JsonProperty;
Expand Down Expand Up @@ -37,7 +37,7 @@ class RuntimeTypeNodeFactory implements TypeNodeFactory

private static function fromGeneric(string $type): TypeNode
{
[, $generics] = TypeParser::getGenericTypes($type);
[, $generics] = DocBlockTypeResolver::instance()->genericTypeParts($type);

return new TypeNode(
type: $type,
Expand All @@ -47,7 +47,7 @@ private static function fromGeneric(string $type): TypeNode

private static function fromShape(string $type): TypeNode
{
[$shapeType, $params] = TypeParser::getParamMapFromShape($type);
[$shapeType, $params] = DocBlockTypeResolver::instance()->shapeMemberStrings($type);

return new TypeNode(
type: $type,
Expand All @@ -67,7 +67,7 @@ private static function fromShape(string $type): TypeNode

private static function fromArray(string $type): TypeNode
{
[, $generics] = TypeParser::getGenericTypes($type);
[, $generics] = DocBlockTypeResolver::instance()->genericTypeParts($type);
$key = $generics[0] ?? 'mixed';
$value = $generics[1] ?? 'mixed';

Expand Down
20 changes: 20 additions & 0 deletions tests/Fixture/Money.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Test\Tcds\Io\Jackson\Fixture;

use Tcds\Io\Jackson\Node\JsonMapper;

#[
JsonMapper(
reader: MoneyReader::class,
writer: MoneyWriter::class,
)
]
readonly class Money
{
public function __construct(public int $cents)
{
}
}
35 changes: 35 additions & 0 deletions tests/Fixture/MoneyReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Test\Tcds\Io\Jackson\Fixture;

use Override;
use Tcds\Io\Jackson\Node\StaticReader;
use Tcds\Io\Jackson\ObjectMapper;

/**
* @implements StaticReader<Money>
*/
final class MoneyReader implements StaticReader
{
/**
* @param list<string> $path
*/
#[Override] public static function read(mixed $data, string $type, ObjectMapper $mapper, array $path): ?Money
{
if ($data === null) {
return null;
}

if (is_int($data)) {
return new Money($data);
}

if (is_string($data) && preg_match('/^\$(?<value>\d+(?:\.\d{1,2})?)$/', $data, $matches) === 1) {
return new Money((int) round((float) $matches['value'] * 100));
}

throw new \InvalidArgumentException(sprintf('Cannot parse Money from %s', get_debug_type($data)));
}
}
Loading
Loading