Skip to content

Commit

Permalink
Resolve collections from DocBlock (#1214)
Browse files Browse the repository at this point in the history
enable docblock type resolver as opt-in feature
  • Loading branch information
dgafka authored Oct 15, 2020
1 parent 8390baf commit 8d7113e
Show file tree
Hide file tree
Showing 31 changed files with 928 additions and 26 deletions.
9 changes: 9 additions & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@
<property name="searchAnnotations" type="boolean" value="true"/>
</properties>
</rule>
<rule ref="SlevomatCodingStandard.Namespaces.MultipleUsesPerLine.MultipleUsesPerLine">
<exclude-pattern>tests/*</exclude-pattern>
</rule>
<rule ref="PSR2.Namespaces.UseDeclaration.MultipleDeclarations">
<exclude-pattern>tests/*</exclude-pattern>
</rule>
<rule ref="SlevomatCodingStandard.Namespaces.DisallowGroupUse.DisallowedGroupUse">
<exclude-pattern>tests/*</exclude-pattern>
</rule>

<rule ref="PSR1.Classes.ClassDeclaration.MultipleClasses">
<exclude-pattern>tests/*</exclude-pattern>
Expand Down
86 changes: 86 additions & 0 deletions src/Metadata/Driver/DocBlockDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace JMS\Serializer\Metadata\Driver;

use JMS\Serializer\Metadata\ClassMetadata as SerializerClassMetadata;
use JMS\Serializer\Metadata\ExpressionPropertyMetadata;
use JMS\Serializer\Metadata\PropertyMetadata;
use JMS\Serializer\Metadata\StaticPropertyMetadata;
use JMS\Serializer\Metadata\VirtualPropertyMetadata;
use JMS\Serializer\Type\Parser;
use JMS\Serializer\Type\ParserInterface;
use Metadata\ClassMetadata;
use Metadata\Driver\DriverInterface;
use ReflectionClass;
use ReflectionException;
use ReflectionProperty;

class DocBlockDriver implements DriverInterface
{
/**
* @var DriverInterface
*/
protected $delegate;

/**
* @var ParserInterface
*/
protected $typeParser;
/**
* @var DocBlockTypeResolver
*/
private $docBlockTypeResolver;

public function __construct(DriverInterface $delegate, ?ParserInterface $typeParser = null)
{
$this->delegate = $delegate;
$this->typeParser = $typeParser ?: new Parser();
$this->docBlockTypeResolver = new DocBlockTypeResolver();
}

public function loadMetadataForClass(ReflectionClass $class): ?ClassMetadata
{
$classMetadata = $this->delegate->loadMetadataForClass($class);
\assert($classMetadata instanceof SerializerClassMetadata);

if (null === $classMetadata) {
return null;
}

// We base our scan on the internal driver's property list so that we
// respect any internal white/blacklisting like in the AnnotationDriver
foreach ($classMetadata->propertyMetadata as $key => $propertyMetadata) {
// If the inner driver provides a type, don't guess anymore.
if ($propertyMetadata->type || $this->isVirtualProperty($propertyMetadata)) {
continue;
}

try {
$propertyReflection = $this->getReflection($propertyMetadata);

$type = $this->docBlockTypeResolver->getPropertyDocblockTypeHint($propertyReflection);
if ($type) {
$propertyMetadata->setType($this->typeParser->parse($type));
}
} catch (ReflectionException $e) {
continue;
}
}

return $classMetadata;
}

private function isVirtualProperty(PropertyMetadata $propertyMetadata): bool
{
return $propertyMetadata instanceof VirtualPropertyMetadata
|| $propertyMetadata instanceof StaticPropertyMetadata
|| $propertyMetadata instanceof ExpressionPropertyMetadata;
}

private function getReflection(PropertyMetadata $propertyMetadata): ReflectionProperty
{
return new ReflectionProperty($propertyMetadata->class, $propertyMetadata->name);
}
}
35 changes: 35 additions & 0 deletions src/Metadata/Driver/DocBlockDriverFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace JMS\Serializer\Metadata\Driver;

use Doctrine\Common\Annotations\Reader;
use JMS\Serializer\Builder\DriverFactoryInterface;
use JMS\Serializer\Type\ParserInterface;
use Metadata\Driver\DriverInterface;

class DocBlockDriverFactory implements DriverFactoryInterface
{
/**
* @var DriverFactoryInterface
*/
private $driverFactoryToDecorate;
/**
* @var ParserInterface|null
*/
private $typeParser;

public function __construct(DriverFactoryInterface $driverFactoryToDecorate, ?ParserInterface $typeParser = null)
{
$this->driverFactoryToDecorate = $driverFactoryToDecorate;
$this->typeParser = $typeParser;
}

public function createDriver(array $metadataDirs, Reader $annotationReader): DriverInterface
{
$driver = $this->driverFactoryToDecorate->createDriver($metadataDirs, $annotationReader);

return new DocBlockDriver($driver, $this->typeParser);
}
}
161 changes: 161 additions & 0 deletions src/Metadata/Driver/DocBlockTypeResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

declare(strict_types=1);

namespace JMS\Serializer\Metadata\Driver;

class DocBlockTypeResolver
{
/** resolve type hints from property */
private const CLASS_PROPERTY_TYPE_HINT_REGEX = '#@var[\s]*([^\n\$]*)#';
/** resolve single use statements */
private const SINGLE_USE_STATEMENTS_REGEX = '/^[^\S\r\n]*use[\s]*([^;\n]*)[\s]*;$/m';
/** resolve group use statements */
private const GROUP_USE_STATEMENTS_REGEX = '/^[^\S\r\n]*use[[\s]*([^;\n]*)[\s]*{([a-zA-Z0-9\s\n\r,]*)};$/m';
private const GLOBAL_NAMESPACE_PREFIX = '\\';

public function getPropertyDocblockTypeHint(\ReflectionProperty $reflectionProperty): ?string
{
if (!$reflectionProperty->getDocComment()) {
return null;
}

preg_match_all(self::CLASS_PROPERTY_TYPE_HINT_REGEX, $reflectionProperty->getDocComment(), $matchedDocBlockParameterTypes);
if (!isset($matchedDocBlockParameterTypes[1][0])) {
return null;
}

$typeHint = trim($matchedDocBlockParameterTypes[1][0]);
if ($this->isArrayWithoutAnyType($typeHint)) {
return null;
}

$unionTypeHint = [];
foreach (explode('|', $typeHint) as $singleTypeHint) {
if ('null' !== $singleTypeHint) {
$unionTypeHint[] = $singleTypeHint;
}
}

$typeHint = implode('|', $unionTypeHint);
if (count($unionTypeHint) > 1) {
throw new \InvalidArgumentException(sprintf("Can't use union type %s for collection in %s:%s", $typeHint, $reflectionProperty->getDeclaringClass()->getName(), $reflectionProperty->getName()));
}

if (false !== strpos($typeHint, 'array<')) {
$resolvedTypes = [];
preg_match_all('#array<(.*)>#', $typeHint, $genericTypesToResolve);
$genericTypesToResolve = $genericTypesToResolve[1][0];
foreach (explode(',', $genericTypesToResolve) as $genericTypeToResolve) {
$resolvedTypes[] = $this->resolveType(trim($genericTypeToResolve), $reflectionProperty);
}

return 'array<' . implode(',', $resolvedTypes) . '>';
} elseif (false !== strpos($typeHint, '[]')) {
$typeHint = rtrim($typeHint, '[]');
$typeHint = $this->resolveType($typeHint, $reflectionProperty);

return 'array<' . $typeHint . '>';
}

return $this->resolveType($typeHint, $reflectionProperty);
}

private function expandClassNameUsingUseStatements(string $typeHint, \ReflectionClass $declaringClass, \ReflectionProperty $reflectionProperty): string
{
if (class_exists($typeHint)) {
return $typeHint;
}

$expandedClassName = $declaringClass->getNamespaceName() . '\\' . $typeHint;
if (class_exists($expandedClassName)) {
return $expandedClassName;
}

$classContents = file_get_contents($declaringClass->getFileName());
$foundUseStatements = $this->gatherGroupUseStatements($classContents);
$foundUseStatements = array_merge($this->gatherSingleUseStatements($classContents), $foundUseStatements);

foreach ($foundUseStatements as $statementClassName) {
if ($alias = explode('as', $statementClassName)) {
if (array_key_exists(1, $alias) && trim($alias[1]) === $typeHint) {
return trim($alias[0]);
}
}

if ($this->endsWith($statementClassName, $typeHint)) {
return $statementClassName;
}
}

throw new \InvalidArgumentException(sprintf("Can't use incorrect type %s for collection in %s:%s", $typeHint, $declaringClass->getName(), $reflectionProperty->getName()));
}

private function endsWith(string $statementClassToCheck, string $typeHintToSearchFor): bool
{
$typeHintToSearchFor = '\\' . $typeHintToSearchFor;

return substr($statementClassToCheck, -strlen($typeHintToSearchFor)) === $typeHintToSearchFor;
}

private function isPrimitiveType(string $type): bool
{
return in_array($type, ['int', 'float', 'bool', 'string']);
}

private function hasGlobalNamespacePrefix(string $typeHint): bool
{
return self::GLOBAL_NAMESPACE_PREFIX === $typeHint[0];
}

private function gatherGroupUseStatements(string $classContents): array
{
$foundUseStatements = [];
preg_match_all(self::GROUP_USE_STATEMENTS_REGEX, $classContents, $foundGroupUseStatements);
for ($useStatementIndex = 0; $useStatementIndex < count($foundGroupUseStatements[0]); $useStatementIndex++) {
foreach (explode(',', $foundGroupUseStatements[2][$useStatementIndex]) as $singleUseStatement) {
$foundUseStatements[] = trim($foundGroupUseStatements[1][$useStatementIndex]) . trim($singleUseStatement);
}
}

return $foundUseStatements;
}

private function gatherSingleUseStatements(string $classContents): array
{
$foundUseStatements = [];
preg_match_all(self::SINGLE_USE_STATEMENTS_REGEX, $classContents, $foundSingleUseStatements);
for ($useStatementIndex = 0; $useStatementIndex < count($foundSingleUseStatements[0]); $useStatementIndex++) {
$foundUseStatements[] = trim($foundSingleUseStatements[1][$useStatementIndex]);
}

return $foundUseStatements;
}

private function getDeclaringClassOrTrait(\ReflectionProperty $reflectionProperty): \ReflectionClass
{
foreach ($reflectionProperty->getDeclaringClass()->getTraits() as $trait) {
foreach ($trait->getProperties() as $traitProperty) {
if ($traitProperty->getName() === $reflectionProperty->getName()) {
return $this->getDeclaringClassOrTrait($traitProperty);
}
}
}

return $reflectionProperty->getDeclaringClass();
}

private function resolveType(string $typeHint, \ReflectionProperty $reflectionProperty): string
{
if (!$this->hasGlobalNamespacePrefix($typeHint) && !$this->isPrimitiveType($typeHint)) {
$typeHint = $this->expandClassNameUsingUseStatements($typeHint, $this->getDeclaringClassOrTrait($reflectionProperty), $reflectionProperty);
}

return ltrim($typeHint, '\\');
}

private function isArrayWithoutAnyType(string $typeHint): bool
{
return 'array' === $typeHint;
}
}
28 changes: 15 additions & 13 deletions src/Metadata/Driver/TypedPropertiesDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ public function loadMetadataForClass(ReflectionClass $class): ?ClassMetadata
try {
$propertyReflection = $this->getReflection($propertyMetadata);
if ($this->shouldTypeHint($propertyReflection)) {
$propertyMetadata->setType($this->typeParser->parse($propertyReflection->getType()->getName()));
$type = $propertyReflection->getType()->getName();

$propertyMetadata->setType($this->typeParser->parse($type));
}
} catch (ReflectionException $e) {
continue;
Expand All @@ -88,6 +90,18 @@ public function loadMetadataForClass(ReflectionClass $class): ?ClassMetadata
return $classMetadata;
}

private function isVirtualProperty(PropertyMetadata $propertyMetadata): bool
{
return $propertyMetadata instanceof VirtualPropertyMetadata
|| $propertyMetadata instanceof StaticPropertyMetadata
|| $propertyMetadata instanceof ExpressionPropertyMetadata;
}

private function getReflection(PropertyMetadata $propertyMetadata): ReflectionProperty
{
return new ReflectionProperty($propertyMetadata->class, $propertyMetadata->name);
}

private function shouldTypeHint(ReflectionProperty $propertyReflection): bool
{
if (null === $propertyReflection->getType()) {
Expand All @@ -100,16 +114,4 @@ private function shouldTypeHint(ReflectionProperty $propertyReflection): bool

return class_exists($propertyReflection->getType()->getName());
}

private function getReflection(PropertyMetadata $propertyMetadata): ReflectionProperty
{
return new ReflectionProperty($propertyMetadata->class, $propertyMetadata->name);
}

private function isVirtualProperty(PropertyMetadata $propertyMetadata): bool
{
return $propertyMetadata instanceof VirtualPropertyMetadata
|| $propertyMetadata instanceof StaticPropertyMetadata
|| $propertyMetadata instanceof ExpressionPropertyMetadata;
}
}
17 changes: 17 additions & 0 deletions src/SerializerBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
use JMS\Serializer\Handler\HandlerRegistryInterface;
use JMS\Serializer\Handler\IteratorHandler;
use JMS\Serializer\Handler\StdClassHandler;
use JMS\Serializer\Metadata\Driver\DocBlockDriverFactory;
use JMS\Serializer\Naming\CamelCaseNamingStrategy;
use JMS\Serializer\Naming\PropertyNamingStrategyInterface;
use JMS\Serializer\Naming\SerializedNameAnnotationStrategy;
Expand Down Expand Up @@ -165,6 +166,11 @@ final class SerializerBuilder
*/
private $metadataCache;

/**
* @var bool
*/
private $docBlockTyperResolver;

/**
* @param mixed ...$args
*
Expand Down Expand Up @@ -504,6 +510,13 @@ public function setMetadataCache(CacheInterface $cache): self
return $this;
}

public function setDocBlockTypeResolver(bool $docBlockTypeResolver): self
{
$this->docBlockTyperResolver = $docBlockTypeResolver;

return $this;
}

public function build(): Serializer
{
$annotationReader = $this->annotationReader;
Expand All @@ -526,6 +539,10 @@ public function build(): Serializer
);
}

if ($this->docBlockTyperResolver) {
$this->driverFactory = new DocBlockDriverFactory($this->driverFactory, $this->typeParser);
}

$metadataDriver = $this->driverFactory->createDriver($this->metadataDirs, $annotationReader);
$metadataFactory = new MetadataFactory($metadataDriver, null, $this->debug);

Expand Down
Loading

0 comments on commit 8d7113e

Please sign in to comment.