From b1f660641636819d5adb233b0d2a30886612e083 Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Tue, 2 Dec 2025 15:10:32 +0100 Subject: [PATCH 1/2] feat: Handle complexe type annotations (#FRAM-215) --- src/Metadata/Driver/AnnotationsDriver.php | 140 ++++++++++++----- src/Type/TypeExpressionParser.php | 148 ++++++++++++++++++ src/Type/TypeFactory.php | 4 +- tests/Test/Fixtures/annotations.php | 53 +++++++ .../Metadata/Driver/AnnotationsDriverTest.php | 33 ++++ tests/Test/SerializeWithAnnotationTest.php | 37 +++++ tests/Test/Type/TypeExpressionParserTest.php | 51 ++++++ tests/Test/Type/TypeFactoryTest.php | 26 +++ 8 files changed, 456 insertions(+), 36 deletions(-) create mode 100644 src/Type/TypeExpressionParser.php create mode 100644 tests/Test/Type/TypeExpressionParserTest.php diff --git a/src/Metadata/Driver/AnnotationsDriver.php b/src/Metadata/Driver/AnnotationsDriver.php index 6109c45..e7b6ce3 100644 --- a/src/Metadata/Driver/AnnotationsDriver.php +++ b/src/Metadata/Driver/AnnotationsDriver.php @@ -5,6 +5,7 @@ use Bdf\Serializer\Metadata\Builder\ClassMetadataBuilder; use Bdf\Serializer\Metadata\ClassMetadata; use Bdf\Serializer\Type\Type; +use Bdf\Serializer\Type\TypeExpressionParser; use phpDocumentor\Reflection\DocBlock; use phpDocumentor\Reflection\DocBlock\Tag; use phpDocumentor\Reflection\DocBlockFactory; @@ -13,6 +14,8 @@ use ReflectionClass; use ReflectionProperty; +use function explode; + /** * AnnotationsDriver * @@ -32,13 +35,37 @@ class AnnotationsDriver implements DriverInterface */ private $contextFactory; + /** + * All known alias from phpdoc that should be mapped to a serializer type + * + * @var array + */ + public $typeMapping = [ + 'bool' => Type::BOOLEAN, + 'false' => Type::BOOLEAN, + 'true' => Type::BOOLEAN, + 'int' => Type::INTEGER, + 'void' => Type::TNULL, + 'scalar' => Type::STRING, + 'iterable' => Type::TARRAY, + 'list' => Type::TARRAY, + 'object' => \stdClass::class, + 'callback' => 'callable', + 'non-empty-string' => Type::STRING, + 'non-empty-list' => Type::TARRAY, + 'non-empty-array' => Type::TARRAY, + ]; + /** * AnnotationsDriver constructor. + * + * @param array $typeMapping Additional type mapping */ - public function __construct() + public function __construct(array $typeMapping = []) { $this->docBlockFactory = DocBlockFactory::createInstance(); $this->contextFactory = new ContextFactory(); + $this->typeMapping += $typeMapping; } /** @@ -55,13 +82,15 @@ public function getMetadataForClass(ReflectionClass $class): ?ClassMetadata // Get all properties annotations from the hierarchy do { + $templates = $this->getClassTemplates($reflection); + foreach ($this->getClassProperties($reflection) as $property) { // PHP serialize behavior: we skip the static properties. if ($property->isStatic()) { continue; } - $annotation = $this->getPropertyAnnotations($property); + $annotation = $this->getPropertyAnnotations($property, $templates); if (isset($annotation['SerializeIgnore'])) { continue; @@ -128,10 +157,11 @@ private function getClassProperties(ReflectionClass $reflection): array * Get annotations from the property * * @param ReflectionProperty $property + * @param array $templates The class templates * * @return array */ - private function getPropertyAnnotations(ReflectionProperty $property): array + private function getPropertyAnnotations(ReflectionProperty $property, array $templates): array { try { $tags = $this->docBlockFactory->create($property, $this->contextFactory->createFromReflector($property))->getTags(); @@ -143,7 +173,7 @@ private function getPropertyAnnotations(ReflectionProperty $property): array // Tags mapping foreach ($tags as $tag) { - list($option, $value) = $this->createSerializationTag($tag, $property); + list($option, $value) = $this->createSerializationTag($tag, $property, $templates); if ($option !== null && !isset($annotations[$option])) { $annotations[$option] = $value; @@ -152,30 +182,65 @@ private function getPropertyAnnotations(ReflectionProperty $property): array // Adding php type if no precision has been added with annotation if (PHP_VERSION_ID >= 70400 && ($type = $property->getType()) && $type instanceof \ReflectionNamedType && !isset($annotations['type'])) { - $annotations['type'] = $this->findType($type->getName(), $property); + $annotations['type'] = $this->findType($type->getName(), $property, []); // Templates are not applicable here } return $annotations; } + /** + * Get the class templates + * + * @param ReflectionClass $class + * + * @return array The key is the template name, the value is the description + */ + private function getClassTemplates(ReflectionClass $class): array + { + try { + $tags = $this->docBlockFactory->create($class, $this->contextFactory->createFromReflector($class))->getTags(); + } catch (\InvalidArgumentException $e) { + $tags = []; + } + + $templates = []; + + foreach ($tags as $tag) { + if (in_array($tag->getName(), ['template', 'template-covariant', 'template-contravariant', 'psalm-template', 'phpstan-template'], true)) { + /** @var DocBlock\Tags\BaseTag $tag */ + $parts = explode(' ', trim((string) $tag), 2); + $type = trim($parts[0]); + $description = trim($parts[1] ?? ''); + + if ($type !== '') { + $templates[$type] = $description; + $templates[ltrim($class->getNamespaceName() . '\\' . $type, '\\')] = $description; + } + } + } + + return $templates; + } + /** * Create the serialization info * * @param Tag $tag * @param ReflectionProperty $property + * @param array $templates The class templates * * @return array */ - private function createSerializationTag($tag, $property): array + private function createSerializationTag($tag, $property, array $templates): array { switch ($tag->getName()) { case 'var': if ($tag instanceof DocBlock\Tags\InvalidTag) { - return ['type', $this->findType((string) $tag, $property)]; + return ['type', $this->findType((string) $tag, $property, $templates)]; } /** @var DocBlock\Tags\Var_ $tag */ - return ['type', $this->findType((string)$tag->getType(), $property)]; + return ['type', $this->findType((string)$tag->getType(), $property, $templates)]; case 'since': /** @var DocBlock\Tags\Since $tag */ @@ -197,47 +262,52 @@ private function createSerializationTag($tag, $property): array * * @param string $var * @param ReflectionProperty $property + * @param array $templates The class templates * * @return string */ - private function findType($var, $property): ?string + private function findType($var, $property, array $templates): ?string { - // Clear psalm structure notation and generics - $var = preg_replace('/(.*)\{.*\}/u', '$1', $var); - $var = preg_replace('/(.*)<.*>/u', '$1', $var); - // All known alias from phpdoc that should be mapped to a serializer type $alias = [ - 'bool' => Type::BOOLEAN, - 'false' => Type::BOOLEAN, - 'true' => Type::BOOLEAN, - 'int' => Type::INTEGER, - 'void' => Type::TNULL, - 'scalar' => Type::STRING, - 'iterable' => Type::TARRAY, - 'object' => \stdClass::class, - 'callback' => 'callable', - 'self' => $property->class, - '$this' => $property->class, - 'static' => $property->class, - ]; - - if (strpos($var, '|') === false) { - $var = ltrim($var, '\\'); - - return isset($alias[$var]) ? $alias[$var] : $var; - } - - foreach (explode('|', $var) as $candidate) { + 'self' => $property->class, + '$this' => $property->class, + 'static' => $property->class, + ] + + $this->typeMapping + + array_fill_keys(array_keys($templates), Type::MIXED) // Do not resolve the actual template type, use mixed instead + ; + + foreach (TypeExpressionParser::parseString($var) as $intersection) { + // Only take in account the first type of the intersection + $candidate = $intersection[0][0]; $candidate = ltrim($candidate, '\\'); if (isset($alias[$candidate])) { $candidate = $alias[$candidate]; } - if ($candidate !== '' && $candidate !== Type::TNULL) { + if ($candidate === '' || $candidate === Type::TNULL) { + continue; + } + + // Only support types with at most one simple generic type, so if there is more, we skip it + if ( + count($intersection[0]) !== 2 // more than one generic type + || count($intersection[0][1]) !== 1 // the generic type is an union + || count($intersection[0][1][0]) !== 1 // the generic type is an intersection + ) { return $candidate; } + + $generic = ltrim($intersection[0][1][0][0][0], '\\'); + + if (isset($alias[$generic])) { + $generic = $alias[$generic]; + } + + // Ignore more complex generic types for now + return $candidate . '<' . $generic . '>'; } // We let here the getMetadataForClass add the default type diff --git a/src/Type/TypeExpressionParser.php b/src/Type/TypeExpressionParser.php new file mode 100644 index 0000000..10f408a --- /dev/null +++ b/src/Type/TypeExpressionParser.php @@ -0,0 +1,148 @@ +', ',', '[', ']']; + + /** + * Parse a type expression string into a structured array. + * + * The first array level represents union types (separated by '|'). + * The second array level represents intersection types (separated by '&'). + * Each atomic type is represented as an array where the first element is the type name, + * and subsequent elements are arrays of generic type parameters. + * Generic parameters themselves can be union or intersection types (so they follow the same structure). + * + * Example: + * 'int' => [[['int']]] + * 'A&B|C' => [[['A'], ['B']], [['C']]] + * 'Map' => [[['Map', [[['K']]], [[['V']]]]]] + * + * @param string $type + * @return array + */ + public static function parseString(string $type): array + { + $state = new TypeExpressionParserState(preg_split('/([' . preg_quote(implode(self::META_TOKENS)) . '])/', $type, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE)); + + return self::parseUnionType($state); + } + + private static function parseUnionType(TypeExpressionParserState $state): array + { + $types = []; + + do { + $types[] = self::parseIntersectionType($state); + } while ($state->consume('|')); + + return $types; + } + + private static function parseIntersectionType(TypeExpressionParserState $state): array + { + $types = []; + + do { + $types[] = self::parseAtomicType($state); + } while ($state->consume('&')); + + return $types; + } + + private static function parseAtomicType(TypeExpressionParserState $state): array + { + $type = [ + $state->isSymbol() ? trim($state->next()) : 'mixed' + ]; + + if ($state->consume('<')) { + array_push($type, ...self::parseGenerics($state)); + $state->consume('>'); + } + + if ($state->consume('{')) { + // array/object shape is ignored for now + $depth = 1; + + while ($state->hasMoreTokens() && $depth > 0) { + if ($state->consume('{')) { + ++$depth; + } elseif ($state->consume('}')) { + --$depth; + } else { + $state->next(); + } + } + } + + while ($state->consume('[') && $state->consume(']')) { + $type[0] .= '[]'; + } + + return $type; + } + + private static function parseGenerics(TypeExpressionParserState $state): array + { + $types = []; + + do { + $types[] = self::parseUnionType($state); + } while ($state->consume(',')); + + return $types; + } +} + +/** + * @internal + */ +final class TypeExpressionParserState +{ + /** + * @var list + */ + public $tokens; + public $position = 0; + + /** + * @param string[] $tokens + */ + public function __construct(array $tokens) + { + $this->tokens = $tokens; + } + + public function isSymbol(): bool + { + return $this->hasMoreTokens() && !in_array($this->tokens[$this->position], TypeExpressionParser::META_TOKENS, true); + } + + public function hasMoreTokens(): bool + { + return $this->position < count($this->tokens); + } + + public function consume(string $expected): bool + { + if ($this->hasMoreTokens() && $this->tokens[$this->position] === $expected) { + ++$this->position; + return true; + } + + return false; + } + + public function next(): string + { + return $this->tokens[$this->position++]; + } +} diff --git a/src/Type/TypeFactory.php b/src/Type/TypeFactory.php index dee011a..3a741a1 100644 --- a/src/Type/TypeFactory.php +++ b/src/Type/TypeFactory.php @@ -34,6 +34,7 @@ class TypeFactory Type::TARRAY => true, 'int' => true, 'bool' => true, + 'list' => true, ]; /** @@ -64,12 +65,13 @@ public static function createType($type): Type $collectionType = substr($type, 0, -2); $type = Type::TARRAY; $collection = true; - } elseif ($type === Type::TARRAY) { // array + } elseif ($type === Type::TARRAY || $type === 'list') { // array $collectionType = Type::MIXED; $collection = true; } elseif (($pos = strpos($type, '<')) !== false && $type[strlen($type) - 1] === '>') { // Type syntax $collectionType = substr($type, $pos + 1, -1); $type = substr($type, 0, $pos); + $collection = $type === 'list' || $type === Type::TARRAY; } if ($collectionType) { diff --git a/tests/Test/Fixtures/annotations.php b/tests/Test/Fixtures/annotations.php index 72d3a3f..6515f09 100644 --- a/tests/Test/Fixtures/annotations.php +++ b/tests/Test/Fixtures/annotations.php @@ -99,4 +99,57 @@ class WithPsalmAnnotation * @var \ArrayObject */ public $withGenerics; + + /** + * @var \ArrayObject + */ + public $withSingleGeneric; + + /** + * @var non-empty-string + */ + public $nonEmptyString; +} + +/** + * @template K + * @template V + */ +class Token +{ + /** + * @var K + */ + public $key; + + /** + * @var V + */ + public $value; + + public function __construct($key, $value) + { + $this->key = $key; + $this->value = $value; + } +} + +/** + * @template T + * @template V + */ +class Lexer +{ + /** + * @var list> + */ + public $tokens = []; + + /** + * @param list> $tokens + */ + public function __construct(array $tokens) + { + $this->tokens = $tokens; + } } diff --git a/tests/Test/Metadata/Driver/AnnotationsDriverTest.php b/tests/Test/Metadata/Driver/AnnotationsDriverTest.php index 5574aa1..4abb72f 100644 --- a/tests/Test/Metadata/Driver/AnnotationsDriverTest.php +++ b/tests/Test/Metadata/Driver/AnnotationsDriverTest.php @@ -2,9 +2,12 @@ namespace Bdf\Serializer\Metadata\Driver; +use Bdf\Serializer\Lexer; use Bdf\Serializer\Metadata\ClassMetadata; use Bdf\Serializer\Metadata\Driver\Bdf\Customer; use Bdf\Serializer\Metadata\Driver\Bdf\User; +use Bdf\Serializer\Person; +use Bdf\Serializer\Token; use Bdf\Serializer\Type\Type; use Bdf\Serializer\WithPsalmAnnotation; use DateTime; @@ -63,5 +66,35 @@ public function test_load_annotations_with_psalm_types() $this->assertEquals('array', $metadata->property('arrayStructure')->type()->name()); $this->assertEquals(\ArrayObject::class, $metadata->property('withGenerics')->type()->name()); + $this->assertEquals(\ArrayObject::class, $metadata->property('withSingleGeneric')->type()->name()); + $this->assertEquals(Person::class, $metadata->property('withSingleGeneric')->type()->subType()->name()); + $this->assertEquals('string', $metadata->property('nonEmptyString')->type()->name()); + $this->assertTrue($metadata->property('nonEmptyString')->type()->isBuildin()); + } + + /** + * @group test + */ + public function test_load_annotations_with_template() + { + $driver = new AnnotationsDriver(); + + $reflection = new ReflectionClass(Lexer::class); + $metadata = $driver->getMetadataForClass($reflection); + + $this->assertInstanceOf(ClassMetadata::class, $metadata); + $this->assertEquals(Lexer::class, $metadata->name()); + + $this->assertEquals('array', $metadata->property('tokens')->type()->name()); + $this->assertTrue($metadata->property('tokens')->type()->isArray()); + $this->assertEquals(Token::class, $metadata->property('tokens')->type()->subType()->name()); + $this->assertFalse($metadata->property('tokens')->type()->subType()->isArray()); + $this->assertNull($metadata->property('tokens')->type()->subType()->subType()); + + $reflection = new ReflectionClass(Token::class); + $metadata = $driver->getMetadataForClass($reflection); + $this->assertEquals(Token::class, $metadata->name()); + $this->assertEquals('mixed', $metadata->property('key')->type()->name()); + $this->assertEquals('mixed', $metadata->property('value')->type()->name()); } } diff --git a/tests/Test/SerializeWithAnnotationTest.php b/tests/Test/SerializeWithAnnotationTest.php index 10feb6a..040d1c2 100644 --- a/tests/Test/SerializeWithAnnotationTest.php +++ b/tests/Test/SerializeWithAnnotationTest.php @@ -105,13 +105,50 @@ public function test_with_psalm_type() $o->arrayStructure = ['foo' => 'bar', [1, 2, 3]]; $o->withGenerics = new \ArrayObject([4, 5, 6]); + $p = new Person(); + $p->firstName = 'alice'; + $p->lastName = 'smith'; + $o->withSingleGeneric = new \ArrayObject([$p]); + $serialized = $serializer->toArray($o); $this->assertSame([ 'arrayStructure' => ['foo' => 'bar', [1, 2, 3]], 'withGenerics' => [4, 5, 6], + 'withSingleGeneric' => [ + [ + 'lastName' => 'smith', + ], + ], ], $serialized); + $expected = clone $o; + $expected->withSingleGeneric[0]->firstName = 'reload'; + $this->assertEquals($o, $serializer->fromArray($serialized, WithPsalmAnnotation::class)); } + + /** + * + */ + public function test_with_template_type() + { + $serializer = SerializerBuilder::create()->build(); + + $o = new Lexer([ + new Token(42, 'foo'), + new Token(5, '123'), + ]); + + $serialized = $serializer->toArray($o); + + $this->assertSame([ + 'tokens' => [ + ['key' => 42, 'value' => 'foo'], + ['key' => 5, 'value' => '123'], + ], + ], $serialized); + + $this->assertEquals($o, $serializer->fromArray($serialized, Lexer::class)); + } } diff --git a/tests/Test/Type/TypeExpressionParserTest.php b/tests/Test/Type/TypeExpressionParserTest.php new file mode 100644 index 0000000..a60b5e4 --- /dev/null +++ b/tests/Test/Type/TypeExpressionParserTest.php @@ -0,0 +1,51 @@ +assertSame([[['int']]], TypeExpressionParser::parseString('int')); + $this->assertSame([[['\Foo']]], TypeExpressionParser::parseString('\Foo')); + + $this->assertSame([[['string']], [['int']]], TypeExpressionParser::parseString('string|int')); + $this->assertSame([[['Foo'], ['Bar']]], TypeExpressionParser::parseString('Foo&Bar')); + $this->assertSame([[['A'], ['B']], [['C']]], TypeExpressionParser::parseString('A&B|C')); + + $this->assertSame([[['array', [[['string']]]]]], TypeExpressionParser::parseString('array')); + $this->assertSame([[['Map', [[['K']]], [[['V']]]]]], TypeExpressionParser::parseString('Map')); + $this->assertSame([[['list', [[['list', [[['int']]]]]]]]], TypeExpressionParser::parseString('list>')); + + $this->assertSame([[['list', [[['string']], [['int']]]]]], TypeExpressionParser::parseString('list')); + $this->assertSame([[['Foo', [[['Bar'], ['Baz']]]]]], TypeExpressionParser::parseString('Foo')); + $this->assertSame([[['A']], [['B', [[['C']]]]]], TypeExpressionParser::parseString('A|B')); + $this->assertSame([[['Gen', [[['A']], [['B']]], [[['C'], ['D']]]]]], TypeExpressionParser::parseString('Gen')); + + $this->assertSame([[['A']]], TypeExpressionParser::parseString('A{a:int, b:string}')); + $this->assertSame([[['A']]], TypeExpressionParser::parseString('A{a:array{foo: int}, b:string}')); + + $this->assertSame( + [[ + ['Outer', + [[ + ['Inner', [[['X']], [['Y']]]] + ]], + [[['Z']]] + ] + ]], + TypeExpressionParser::parseString('Outer, Z>') + ); + + $this->assertSame([[['mixed']]], TypeExpressionParser::parseString('{foo: string}')); + $this->assertSame([[['mixed'], ['mixed']]], TypeExpressionParser::parseString('&{>foo: string')); + + $this->assertSame([[['list', [[['\MyNs\Token', [[['T']]], [[['V']]]]]]]]], TypeExpressionParser::parseString('list<\MyNs\Token>')); + $this->assertSame([[['Foo[]']]], TypeExpressionParser::parseString('Foo[]')); + $this->assertSame([[['Foo[][]', [[['int']]], [[['string']]]]]], TypeExpressionParser::parseString('Foo[][]')); + } +} diff --git a/tests/Test/Type/TypeFactoryTest.php b/tests/Test/Type/TypeFactoryTest.php index a1113cd..7341266 100644 --- a/tests/Test/Type/TypeFactoryTest.php +++ b/tests/Test/Type/TypeFactoryTest.php @@ -136,6 +136,32 @@ public function test_parametrized_type() $this->assertSame('SubType', $type->subType()->name()); } + /** + * + */ + public function test_parametrized_array() + { + $type = TypeFactory::createType('array'); + + $this->assertTrue($type->isParametrized()); + $this->assertTrue($type->isArray()); + $this->assertSame('array', $type->name()); + $this->assertSame('SubType', $type->subType()->name()); + } + + /** + * + */ + public function test_parametrized_list() + { + $type = TypeFactory::createType('list'); + + $this->assertTrue($type->isParametrized()); + $this->assertTrue($type->isArray()); + $this->assertSame('list', $type->name()); + $this->assertSame('SubType', $type->subType()->name()); + } + /** * */ From 0135463f07a50f31dff3e26e847a183f3620e10d Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Tue, 2 Dec 2025 15:14:35 +0100 Subject: [PATCH 2/2] fix: psalm type errors --- src/Type/TypeExpressionParser.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Type/TypeExpressionParser.php b/src/Type/TypeExpressionParser.php index 10f408a..9ecec86 100644 --- a/src/Type/TypeExpressionParser.php +++ b/src/Type/TypeExpressionParser.php @@ -2,6 +2,7 @@ namespace Bdf\Serializer\Type; +use function array_values; use function count; use function in_array; use function preg_quote; @@ -30,7 +31,7 @@ final class TypeExpressionParser */ public static function parseString(string $type): array { - $state = new TypeExpressionParserState(preg_split('/([' . preg_quote(implode(self::META_TOKENS)) . '])/', $type, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE)); + $state = new TypeExpressionParserState(array_values(preg_split('/([' . preg_quote(implode(self::META_TOKENS)) . '])/', $type, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE))); return self::parseUnionType($state); } @@ -111,10 +112,14 @@ final class TypeExpressionParserState * @var list */ public $tokens; + + /** + * @var non-negative-int + */ public $position = 0; /** - * @param string[] $tokens + * @param list $tokens */ public function __construct(array $tokens) {