Skip to content

Commit

Permalink
feat: Inheritance support.
Browse files Browse the repository at this point in the history
  • Loading branch information
priyadi committed Jan 13, 2024
1 parent 4ddcf91 commit 43ed976
Show file tree
Hide file tree
Showing 25 changed files with 648 additions and 8 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* refactor: Move `Context` to its own namespace.
* style(Context): Rename `set` to `with` and `remove` to `without`.
* refactor: Reintroduce `Context` to `MapperInterface`.
* feat: Inheritance support.

## 0.5.4

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Full documentation is available at [rekalogika.dev/mapper](https://rekalogika.de
* Constructor initialization.
* Handles nested objects.
* Handles recursion and circular references.
* Inheritance support. Maps to abstract classes and interfaces using an
inheritance map attribute.
* Reads the type from PHP type declaration and PHPDoc annotations, including
the type of the nested objects.
* Handles `array`, `ArrayAccess` and `Traversable` objects, and the mapping
Expand All @@ -34,7 +36,6 @@ Full documentation is available at [rekalogika.dev/mapper](https://rekalogika.de

## Future Features

* Mapping to interfaces and abstract classes.
* Option to map to or from different property name? (seems to be a popular
feature, but I prefer the native OOP way of doing it)
* Option to read & write to private properties?
Expand Down
14 changes: 10 additions & 4 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use Rekalogika\Mapper\Transformer\ArrayToObjectTransformer;
use Rekalogika\Mapper\Transformer\CopyTransformer;
use Rekalogika\Mapper\Transformer\DateTimeTransformer;
use Rekalogika\Mapper\Transformer\InheritanceMapTransformer;
use Rekalogika\Mapper\Transformer\NullTransformer;
use Rekalogika\Mapper\Transformer\ObjectToArrayTransformer;
use Rekalogika\Mapper\Transformer\ObjectToObjectTransformer;
Expand Down Expand Up @@ -87,25 +88,29 @@

$services
->set('rekalogika.mapper.transformer.scalar_to_scalar', ScalarToScalarTransformer::class)
->tag('rekalogika.mapper.transformer', ['priority' => -450]);
->tag('rekalogika.mapper.transformer', ['priority' => -400]);

$services
->set('rekalogika.mapper.transformer.datetime', DateTimeTransformer::class)
->tag('rekalogika.mapper.transformer', ['priority' => -500]);
->tag('rekalogika.mapper.transformer', ['priority' => -450]);

$services
->set('rekalogika.mapper.transformer.string_to_backed_enum', StringToBackedEnumTransformer::class)
->tag('rekalogika.mapper.transformer', ['priority' => -550]);
->tag('rekalogika.mapper.transformer', ['priority' => -500]);

$services
->set('rekalogika.mapper.method_mapper.transformer', ClassMethodTransformer::class)
->args([
service('rekalogika.mapper.method_mapper.sub_mapper'),
])
->tag('rekalogika.mapper.transformer', ['priority' => -600]);
->tag('rekalogika.mapper.transformer', ['priority' => -550]);

$services
->set('rekalogika.mapper.transformer.object_to_string', ObjectToStringTransformer::class)
->tag('rekalogika.mapper.transformer', ['priority' => -600]);

$services
->set('rekalogika.mapper.transformer.inheritance_map', InheritanceMapTransformer::class)
->tag('rekalogika.mapper.transformer', ['priority' => -650]);

$services
Expand Down Expand Up @@ -146,6 +151,7 @@
->set('rekalogika.mapper.transformer.null', CopyTransformer::class)
->tag('rekalogika.mapper.transformer', ['priority' => -1000]);


# mappingfactory

$services
Expand Down
43 changes: 43 additions & 0 deletions src/Attribute/InheritanceMap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

/*
* This file is part of rekalogika/mapper package.
*
* (c) Priyadi Iman Nurcahyo <https://rekalogika.dev>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/

namespace Rekalogika\Mapper\Attribute;

#[\Attribute(\Attribute::TARGET_CLASS)]
final readonly class InheritanceMap implements MapperAttributeInterface
{
/**
* @param array<class-string,class-string> $map
*/
public function __construct(
private array $map = []
) {
}

/**
* @return array<class-string,class-string>
*/
public function getMap(): array
{
return $this->map;
}

/**
* @param class-string $sourceClass
* @return class-string|null
*/
public function getTargetClassFromSourceClass(string $sourceClass): ?string
{
return $this->map[$sourceClass] ?? null;
}
}
2 changes: 1 addition & 1 deletion src/Context/Context.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
/**
* @immutable
*/
class Context
final readonly class Context
{
/**
* @param array<class-string,object> $context
Expand Down
13 changes: 13 additions & 0 deletions src/MapperFactory/MapperFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use Rekalogika\Mapper\Transformer\Contracts\TransformerInterface;
use Rekalogika\Mapper\Transformer\CopyTransformer;
use Rekalogika\Mapper\Transformer\DateTimeTransformer;
use Rekalogika\Mapper\Transformer\InheritanceMapTransformer;
use Rekalogika\Mapper\Transformer\NullTransformer;
use Rekalogika\Mapper\Transformer\ObjectToArrayTransformer;
use Rekalogika\Mapper\Transformer\ObjectToObjectTransformer;
Expand Down Expand Up @@ -87,6 +88,7 @@ class MapperFactory
private ?TraversableToTraversableTransformer $traversableToTraversableTransformer = null;
private ?CopyTransformer $copyTransformer = null;
private ?ClassMethodTransformer $classMethodTransformer = null;
private ?InheritanceMapTransformer $inheritanceMapTransformer = null;

private CacheItemPoolInterface $propertyInfoExtractorCache;
private null|(PropertyInfoExtractorInterface&PropertyInitializableExtractorInterface) $propertyInfoExtractor = null;
Expand Down Expand Up @@ -378,6 +380,15 @@ protected function getClassMethodTransformer(): ClassMethodTransformer
return $this->classMethodTransformer;
}

protected function getInheritanceMapTransformer(): InheritanceMapTransformer
{
if (null === $this->inheritanceMapTransformer) {
$this->inheritanceMapTransformer = new InheritanceMapTransformer();
}

return $this->inheritanceMapTransformer;
}

//
// other services
//
Expand Down Expand Up @@ -407,6 +418,8 @@ protected function getTransformersIterator(): iterable
=> $this->getClassMethodTransformer();
yield 'ObjectToStringTransformer'
=> $this->getObjectToStringTransformer();
yield 'InheritanceMapTransformer'
=> $this->getInheritanceMapTransformer();
yield 'TraversableToArrayAccessTransformer'
=> $this->getTraversableToArrayAccessTransformer();
yield 'TraversableToTraversableTransformer'
Expand Down
46 changes: 46 additions & 0 deletions src/Transformer/Exception/NotAClassException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

/*
* This file is part of rekalogika/mapper package.
*
* (c) Priyadi Iman Nurcahyo <https://rekalogika.dev>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/

namespace Rekalogika\Mapper\Transformer\Exception;

use Rekalogika\Mapper\Attribute\InheritanceMap;

class NotAClassException extends NotMappableValueException
{
public function __construct(string $class)
{
/** @var class-string $class */

try {
$reflectionClass = new \ReflectionClass($class);

if ($reflectionClass->isInterface()) {
parent::__construct(sprintf(
'Trying to map to "%s", but it is an interface, not a class. If you want to map to an interface, you need to add the attribute "%s" to the interface."',
$class,
InheritanceMap::class
));
} else {
parent::__construct(sprintf(
'Trying to map to "%s", but it is not a class.',
$class,
));
}
} catch (\ReflectionException) {
parent::__construct(sprintf(
'The name "%s" is not a valid class.',
$class
));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

/*
* This file is part of rekalogika/mapper package.
*
* (c) Priyadi Iman Nurcahyo <https://rekalogika.dev>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/

namespace Rekalogika\Mapper\Transformer\Exception;

use Rekalogika\Mapper\Attribute\InheritanceMap;

class SourceClassNotInInheritanceMapException extends NotMappableValueException
{
/**
* @param class-string $sourceClass
* @param class-string $targetClass
*/
public function __construct(string $sourceClass, string $targetClass)
{
parent::__construct(sprintf(
'Trying to map to a class with an inheritance map, but source class "%s" is not found in the "%s" attribute of the target class "%s"',
$sourceClass,
InheritanceMap::class,
$targetClass
));
}
}
130 changes: 130 additions & 0 deletions src/Transformer/InheritanceMapTransformer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

declare(strict_types=1);

/*
* This file is part of rekalogika/mapper package.
*
* (c) Priyadi Iman Nurcahyo <https://rekalogika.dev>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/

namespace Rekalogika\Mapper\Transformer;

use Rekalogika\Mapper\Attribute\InheritanceMap;
use Rekalogika\Mapper\Context\Context;
use Rekalogika\Mapper\Exception\InvalidArgumentException;
use Rekalogika\Mapper\Exception\UnexpectedValueException;
use Rekalogika\Mapper\ObjectCache\ObjectCache;
use Rekalogika\Mapper\Transformer\Contracts\MainTransformerAwareInterface;
use Rekalogika\Mapper\Transformer\Contracts\MainTransformerAwareTrait;
use Rekalogika\Mapper\Transformer\Contracts\TransformerInterface;
use Rekalogika\Mapper\Transformer\Contracts\TypeMapping;
use Rekalogika\Mapper\Transformer\Exception\SourceClassNotInInheritanceMapException;
use Rekalogika\Mapper\Util\TypeFactory;
use Symfony\Component\PropertyInfo\Type;

final class InheritanceMapTransformer implements
TransformerInterface,
MainTransformerAwareInterface
{
use MainTransformerAwareTrait;

public function transform(
mixed $source,
mixed $target,
?Type $sourceType,
?Type $targetType,
Context $context
): mixed {

# source must be an object

if (!is_object($source)) {
throw new InvalidArgumentException(
\sprintf('Source must be an object, "%s" given.', \get_debug_type($source))
);
}

# target type must exist

if ($targetType === null) {
throw new InvalidArgumentException('Target type must be specified.');
}

# target must be an interface or class

$targetClass = $targetType->getClassName();

if (
$targetClass === null
|| (
!\class_exists($targetClass)
&& !\interface_exists($targetClass)
)
) {
throw new InvalidArgumentException(
\sprintf('Target class "%s" does not exist.', $targetClass ?? 'null')
);
}

# gets the inheritance map

$attributes = (new \ReflectionClass($targetClass))
->getAttributes(InheritanceMap::class);

if (\count($attributes) === 0) {
throw new InvalidArgumentException(
\sprintf('Target class "%s" must have inheritance map.', $targetClass)
);
}

$inheritanceMap = $attributes[0]->newInstance();

# gets the target class from the inheritance map

$sourceClass = \get_class($source);
$targetClassInMap = $inheritanceMap->getTargetClassFromSourceClass($sourceClass);

if ($targetClassInMap === null) {
throw new SourceClassNotInInheritanceMapException($sourceClass, $targetClass);
}

# pass the transformation back to the main transformer

$concreteTargetType = TypeFactory::objectOfClass($targetClassInMap);

$result = $this->getMainTransformer()->transform(
source: $source,
target: null,
targetTypes: [$concreteTargetType],
context: $context
);

# make sure $result is the correct type

if (!is_object($result) || !is_a($result, $targetClassInMap)) {
throw new UnexpectedValueException(
\sprintf('Expecting an instance of "%s", "%s" given.', $targetClassInMap, \get_debug_type($result))
);
}

# cache the result. we cache the abstract class/interface as the key.
# the concrete should be cached by whatever transformer that handles it.

$context->get(ObjectCache::class)
->saveTarget($source, $targetType, $result);

return $result;
}

public function getSupportedTransformation(): iterable
{
yield new TypeMapping(
TypeFactory::object(),
TypeFactory::objectOfClass(InheritanceMap::class)
);
}
}
Loading

0 comments on commit 43ed976

Please sign in to comment.