From bbfefd8ddb4d4df0bdd9770a2e6e7486637b37e9 Mon Sep 17 00:00:00 2001 From: ignace nyamagana butera Date: Fri, 20 Sep 2024 21:42:15 +0200 Subject: [PATCH] Improving denormalization --- src/Serializer/AfterMapping.php | 43 ++++++++++++++++++ src/Serializer/Denormalizer.php | 55 +++++++++++------------ src/Serializer/DenormalizerTest.php | 6 +-- src/Serializer/MapRecord.php | 67 +++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 30 deletions(-) create mode 100644 src/Serializer/MapRecord.php diff --git a/src/Serializer/AfterMapping.php b/src/Serializer/AfterMapping.php index 65d244e0..de698bc5 100644 --- a/src/Serializer/AfterMapping.php +++ b/src/Serializer/AfterMapping.php @@ -14,6 +14,10 @@ namespace League\Csv\Serializer; use Attribute; +use ReflectionAttribute; +use ReflectionClass; +use ReflectionException; +use ReflectionMethod; #[Attribute(Attribute::TARGET_CLASS)] final class AfterMapping @@ -25,4 +29,43 @@ public function __construct(string ...$methods) { $this->methods = $methods; } + + /** + * + * @return array + */ + public function afterMappingMethods(ReflectionClass $class): array + { + $methods = []; + foreach ($this->methods as $method) { + try { + $accessor = $class->getMethod($method); + } catch (ReflectionException $exception) { + throw new MappingFailed('The method `'.$method.'` is not defined on the `'.$class->getName().'` class.', 0, $exception); + } + + if (0 !== $accessor->getNumberOfRequiredParameters()) { + throw new MappingFailed('The method `'.$class->getName().'::'.$accessor->getName().'` has too many required parameters.'); + } + + $methods[] = $accessor; + } + + return $methods; + } + + public static function from(ReflectionClass $class): ?self + { + $attributes = $class->getAttributes(AfterMapping::class, ReflectionAttribute::IS_INSTANCEOF); + $nbAttributes = count($attributes); + if (0 === $nbAttributes) { + return null; + } + + if (1 < $nbAttributes) { + throw new MappingFailed('Using more than one `'.AfterMapping::class.'` attribute on a class property or method is not supported.'); + } + + return $attributes[0]->newInstance(); + } } diff --git a/src/Serializer/Denormalizer.php b/src/Serializer/Denormalizer.php index d5845fc4..86de38c1 100644 --- a/src/Serializer/Denormalizer.php +++ b/src/Serializer/Denormalizer.php @@ -40,6 +40,7 @@ final class Denormalizer private readonly array $propertySetters; /** @var array */ private readonly array $postMapCalls; + private readonly ?MapRecord $mapRecord; /** * @param class-string $className @@ -50,6 +51,7 @@ final class Denormalizer public function __construct(string $className, array $propertyNames = []) { $this->class = $this->setClass($className); + $this->mapRecord = $this->getMapRecord(); $this->properties = $this->class->getProperties(); $this->propertySetters = $this->setPropertySetters($propertyNames); $this->postMapCalls = $this->setPostMapCalls(); @@ -255,6 +257,7 @@ private function setPropertySetters(array $propertyNames): array { $propertySetters = []; $methodNames = array_map(fn (string $propertyName) => 'set'.ucfirst($propertyName), $propertyNames); + foreach ([...$this->properties, ...$this->class->getMethods()] as $accessor) { $attributes = $accessor->getAttributes(MapCell::class, ReflectionAttribute::IS_INSTANCEOF); $propertySetter = match (count($attributes)) { @@ -273,36 +276,28 @@ private function setPropertySetters(array $propertyNames): array }; } - private function setPostMapCalls(): array + private function getMapRecord(): ?MapRecord { - $methods = []; - $attributes = $this->class->getAttributes(AfterMapping::class, ReflectionAttribute::IS_INSTANCEOF); + $attributes = $this->class->getAttributes(MapRecord::class, ReflectionAttribute::IS_INSTANCEOF); $nbAttributes = count($attributes); - if (0 === $nbAttributes) { - return $methods; - } - - if (1 < $nbAttributes) { - throw new MappingFailed('Using more than one `'.AfterMapping::class.'` attribute on a class property or method is not supported.'); - } - - /** @var AfterMapping $postMap */ - $postMap = $attributes[0]->newInstance(); - foreach ($postMap->methods as $method) { - try { - $accessor = $this->class->getMethod($method); - } catch (ReflectionException $exception) { - throw new MappingFailed('The method `'.$method.'` is not defined on the `'.$this->class->getName().'` class.', 0, $exception); - } - if (0 !== $accessor->getNumberOfRequiredParameters()) { - throw new MappingFailed('The method `'.$this->class->getName().'::'.$accessor->getName().'` has too many required parameters.'); - } + return match ($nbAttributes) { + 0 => null, + 1 => $attributes[0]->newInstance(), + default => throw new MappingFailed('Using more than one `'.MapRecord::class.'` attribute on a class property or method is not supported.'), + }; + } - $methods[] = $accessor; + /** + * @return array + */ + private function setPostMapCalls(): array + { + if ($this->mapRecord instanceof MapRecord) { + return $this->mapRecord->afterMappingMethods($this->class); } - return $methods; + return AfterMapping::from($this->class)?->afterMappingMethods($this->class) ?? []; } /** @@ -344,7 +339,10 @@ private function autoDiscoverPropertySetter(ReflectionMethod|ReflectionProperty default => new PropertySetter( $accessor, $offset, - $this->resolveTypeCasting($reflectionProperty) + $this->resolveTypeCasting( + $reflectionProperty, + $this->mapRecord?->resolveOptions() ?? [], + ) ), }; } @@ -386,11 +384,14 @@ private function findPropertySetter(MapCell $cell, ReflectionMethod|ReflectionPr $accessor instanceof ReflectionProperty => $accessor, }; + + $options = $this->mapRecord?->resolveOptions($cell) ?? $cell->options; + return match (true) { 0 > $offset => throw new MappingFailed('offset integer position can only be positive or equals to 0; received `'.$offset.'`'), [] !== $propertyNames && $offset > count($propertyNames) - 1 => throw new MappingFailed('offset integer position can not exceed property names count.'), - null === $typeCaster => new PropertySetter($accessor, $offset, $this->resolveTypeCasting($reflectionProperty, $cell->options)), - default => new PropertySetter($accessor, $offset, $this->getTypeCasting($reflectionProperty, $typeCaster, $cell->options)), + null === $typeCaster => new PropertySetter($accessor, $offset, $this->resolveTypeCasting($reflectionProperty, $options)), + default => new PropertySetter($accessor, $offset, $this->getTypeCasting($reflectionProperty, $typeCaster, $options)), }; } diff --git a/src/Serializer/DenormalizerTest.php b/src/Serializer/DenormalizerTest.php index f954a83f..33f4b516 100644 --- a/src/Serializer/DenormalizerTest.php +++ b/src/Serializer/DenormalizerTest.php @@ -672,7 +672,7 @@ enum Place: string case Abidjan = 'Abidjan'; } -#[AfterMapping('addOne')] +#[MapRecord(afterMapping: ['addOne'])] class UsingAfterMapping { public function __construct(public int $addition) @@ -686,7 +686,7 @@ private function addOne(): void } } -#[AfterMapping('addOne', 'addTow')] +#[MapRecord(afterMapping: ['addOne', 'addTow'])] class MissingMethodAfterMapping { public function __construct(public int $addition) @@ -700,7 +700,7 @@ private function addOne(): void } } -#[AfterMapping('addOne')] +#[MapRecord(afterMapping: ['addOne'])] class RequiresArgumentAfterMapping { public function __construct(public int $addition) diff --git a/src/Serializer/MapRecord.php b/src/Serializer/MapRecord.php new file mode 100644 index 00000000..8ccd85a7 --- /dev/null +++ b/src/Serializer/MapRecord.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace League\Csv\Serializer; + +use Attribute; +use ReflectionClass; +use ReflectionException; +use ReflectionMethod; +use ValueError; + +#[Attribute(Attribute::TARGET_CLASS)] +final class MapRecord +{ + public function __construct( + /** @var array $afterMapping */ + public readonly array $afterMapping, + public readonly bool $allowEmptyStringAsNull = false, + ) { + foreach ($this->afterMapping as $method) { + if (!is_string($method)) { + throw new ValueError('The method names must be strings.'); + } + } + } + + /** + * @return array + */ + public function afterMappingMethods(ReflectionClass $class): array + { + $methods = []; + foreach ($this->afterMapping as $method) { + try { + $accessor = $class->getMethod($method); + } catch (ReflectionException $exception) { + throw new MappingFailed('The method `'.$method.'` is not defined on the `'.$class->getName().'` class.', 0, $exception); + } + + if (0 !== $accessor->getNumberOfRequiredParameters()) { + throw new MappingFailed('The method `'.$class->getName().'::'.$accessor->getName().'` has too many required parameters.'); + } + + $methods[] = $accessor; + } + + return $methods; + } + + public function resolveOptions(?MapCell $mapCell = null): array + { + return [ + ...['allowEmptyStringAsNull' => $this->allowEmptyStringAsNull], + ...$mapCell?->options ?? [], + ]; + } +}