diff --git a/composer.json b/composer.json index b76cea0..c3663ca 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 501d01b..96e4777 100644 --- a/composer.lock +++ b/composer.lock @@ -4,29 +4,77 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "baf026ad1c2ffde8360066e43a112023", + "content-hash": "bde999359cf51f09ae286082bcdc4869", "packages": [ + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, { "name": "tcds-io/php-better-generics", - "version": "0.1.0", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/tcds-io/php-better-generics.git", - "reference": "582d653d4aaabf70697b556051690b7c419c02c0" + "reference": "20dfb6284e14718ff6fe68147a5676751e0c5123" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tcds-io/php-better-generics/zipball/582d653d4aaabf70697b556051690b7c419c02c0", - "reference": "582d653d4aaabf70697b556051690b7c419c02c0", + "url": "https://api.github.com/repos/tcds-io/php-better-generics/zipball/20dfb6284e14718ff6fe68147a5676751e0c5123", + "reference": "20dfb6284e14718ff6fe68147a5676751e0c5123", "shasum": "" }, "require": { - "php": "^8.4" + "php": "^8.4", + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.5", - "slevomat/coding-standard": "^8.15", + "slevomat/coding-standard": "^8.16", "symfony/var-dumper": "^7.2" }, "type": "library", @@ -51,9 +99,9 @@ "description": "PHP library to better work with generics", "support": { "issues": "https://github.com/tcds-io/php-better-generics/issues", - "source": "https://github.com/tcds-io/php-better-generics/tree/0.1.0" + "source": "https://github.com/tcds-io/php-better-generics/tree/1.0.0" }, - "time": "2026-01-13T13:33:25+00:00" + "time": "2026-05-03T15:24:21+00:00" } ], "packages-dev": [ diff --git a/src/ArrayObjectMapper.php b/src/ArrayObjectMapper.php index 1abe6fd..3aefb49 100644 --- a/src/ArrayObjectMapper.php +++ b/src/ArrayObjectMapper.php @@ -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; @@ -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 $value */ return $this->readValue($type, [ @@ -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, @@ -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, @@ -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 } $attributes */ + $attributes = new ReflectionClass($type)->getAttributes(JsonMapper::class); + + /** */ + return $attributes === [] + ? null + : $attributes[0]->newInstance(); + } } diff --git a/src/Node/JsonMapper.php b/src/Node/JsonMapper.php new file mode 100644 index 0000000..baecf88 --- /dev/null +++ b/src/Node/JsonMapper.php @@ -0,0 +1,43 @@ +|Closure|class-string>|null $reader + * @param Writer|Closure|class-string>|null $writer + */ + public function __construct( + public Reader|Closure|string|null $reader = null, + public Writer|Closure|string|null $writer = null, + ) { + } +} diff --git a/src/Node/Runtime/RuntimeReader.php b/src/Node/Runtime/RuntimeReader.php index 3eab41b..ca70149 100644 --- a/src/Node/Runtime/RuntimeReader.php +++ b/src/Node/Runtime/RuntimeReader.php @@ -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; @@ -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); diff --git a/src/Node/Runtime/RuntimeTypeNodeFactory.php b/src/Node/Runtime/RuntimeTypeNodeFactory.php index 16d5e97..f4bf165 100644 --- a/src/Node/Runtime/RuntimeTypeNodeFactory.php +++ b/src/Node/Runtime/RuntimeTypeNodeFactory.php @@ -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; @@ -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, @@ -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, @@ -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'; diff --git a/tests/Fixture/Money.php b/tests/Fixture/Money.php new file mode 100644 index 0000000..84ee961 --- /dev/null +++ b/tests/Fixture/Money.php @@ -0,0 +1,20 @@ + + */ +final class MoneyReader implements StaticReader +{ + /** + * @param list $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('/^\$(?\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))); + } +} diff --git a/tests/Fixture/MoneyWriter.php b/tests/Fixture/MoneyWriter.php new file mode 100644 index 0000000..5c72390 --- /dev/null +++ b/tests/Fixture/MoneyWriter.php @@ -0,0 +1,27 @@ + + */ +final class MoneyWriter implements StaticWriter +{ + /** + * @param list $path + */ + #[Override] public static function write(mixed $data, string $type, ObjectMapper $mapper, array $path): ?string + { + if (!$data instanceof Money) { + return null; + } + + return sprintf('$%.2f', $data->cents / 100); + } +} diff --git a/tests/Fixture/Slug.php b/tests/Fixture/Slug.php new file mode 100644 index 0000000..7fdaa24 --- /dev/null +++ b/tests/Fixture/Slug.php @@ -0,0 +1,18 @@ + $path + */ + public function __invoke(mixed $data, string $type, ObjectMapper $mapper, array $path): ?Slug + { + if ($data === null) { + return null; + } + + $slug = strtolower((string) $data); + $slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? ''; + $slug = trim($slug, '-'); + + return new Slug($slug); + } +} diff --git a/tests/Fixture/SlugWriter.php b/tests/Fixture/SlugWriter.php new file mode 100644 index 0000000..cc4f0f8 --- /dev/null +++ b/tests/Fixture/SlugWriter.php @@ -0,0 +1,22 @@ + $path + */ + public function __invoke(mixed $data, string $type, ObjectMapper $mapper, array $path): ?string + { + return $data instanceof Slug ? $data->value : null; + } +} diff --git a/tests/Unit/Node/JsonMapperTest.php b/tests/Unit/Node/JsonMapperTest.php new file mode 100644 index 0000000..74c5494 --- /dev/null +++ b/tests/Unit/Node/JsonMapperTest.php @@ -0,0 +1,74 @@ +assertEquals(new Money(1050), $this->arrayMapper->readValue(Money::class, 1050)); + $this->assertEquals(new Money(1050), $this->arrayMapper->readValue(Money::class, '$10.50')); + } + + #[Test] + public function write_uses_writer_from_class_attribute(): void + { + $this->assertSame('$10.50', $this->arrayMapper->writeValue(new Money(1050))); + $this->assertSame('"$10.50"', $this->jsonMapper->writeValue(new Money(1050))); + } + + #[Test] + public function class_attribute_wins_over_type_mappers(): void + { + // The attribute lives on the class declaration — that's the canonical + // source of truth, so the typeMappers constructor argument cannot + // override it. + $mapper = new ArrayObjectMapper(typeMappers: [ + Money::class => [ + 'reader' => fn (mixed $data) => new Money(((int) ($data ?? 0)) * 2), + 'writer' => fn (Money $data) => $data->cents, + ], + ]); + + $this->assertEquals(new Money(10), $mapper->readValue(Money::class, 10)); + $this->assertSame('$0.20', $mapper->writeValue(new Money(20))); + } + + #[Test] + public function read_uses_invokable_class_from_class_attribute(): void + { + // SlugReader has __invoke but does not implement Reader — should still work + $this->assertEquals(new Slug('hello-world'), $this->arrayMapper->readValue(Slug::class, 'Hello World!')); + } + + #[Test] + public function write_uses_invokable_class_from_class_attribute(): void + { + // SlugWriter has __invoke but does not implement Writer — should still work + $this->assertSame('hello-world', $this->arrayMapper->writeValue(new Slug('hello-world'))); + } + + #[Test] + public function json_mapper_accepts_closure_when_constructed_programmatically(): void + { + // PHP attributes can't carry literal closures, but JsonMapper itself + // accepts a MapperClosure when built directly (e.g. for tests or + // dynamic registration). The resolver short-circuits on Closure. + $jsonMapper = new \Tcds\Io\Jackson\Node\JsonMapper( + reader: fn (mixed $data) => new Money(((int) ($data ?? 0)) + 1), + writer: fn (Money $data) => $data->cents - 1, + ); + + $this->assertSame(11, ($jsonMapper->writer)(new Money(12), Money::class, $this->arrayMapper, [])); + $this->assertEquals(new Money(13), ($jsonMapper->reader)(12, Money::class, $this->arrayMapper, [])); + } +}