Skip to content

Commit

Permalink
feat: Constructor arguments
Browse files Browse the repository at this point in the history
  • Loading branch information
priyadi committed Jan 12, 2024
1 parent c827cf1 commit 53b5788
Show file tree
Hide file tree
Showing 12 changed files with 541 additions and 36 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

* Add a caching layer for `TypeResolver`
* `TraversableToTraversableTransformer` now accepts `Generator` as a target type
* Constructor arguments

## 0.5.3

Expand Down
25 changes: 25 additions & 0 deletions src/Exception/ClassNotInstantiableException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?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\Exception;

class ClassNotInstantiableException extends NotMappableValueException
{
/**
* @param class-string $class
*/
public function __construct(string $class)
{
parent::__construct(sprintf('Trying to instantiate class "%s", but this class is not instantiable.', $class));
}
}
29 changes: 29 additions & 0 deletions src/Exception/IncompleteConstructorArgument.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?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\Exception;

class IncompleteConstructorArgument extends NotMappableValueException
{
/**
* @param class-string $targetClass
*/
public function __construct(
object $source,
string $targetClass,
string $property,
\Throwable $previous = null
) {
parent::__construct(sprintf('Trying to instantiate target class "%s", but its constructor requires the property "%s", which is missing from the source "%s".', $targetClass, $property, \get_debug_type($source)), 0, $previous);
}
}
57 changes: 57 additions & 0 deletions src/Exception/InstantiationFailureException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?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\Exception;

class InstantiationFailureException extends NotMappableValueException
{
/**
* @param array<string,mixed> $constructorArguments
*/
public function __construct(
object $source,
string $targetClass,
array $constructorArguments,
\Throwable $previous
) {
if (count($constructorArguments) === 0) {
parent::__construct(sprintf(
'Trying to map the source object of type "%s", but failed to instantiate the target object "%s" with no constructor argument.',
\get_debug_type($source),
$targetClass,
), 0, $previous);
} else {
parent::__construct(sprintf(
'Trying to map the source object of type "%s", but failed to instantiate the target object "%s" using constructor arguments: %s.',
\get_debug_type($source),
$targetClass,
self::formatConstructorArguments($constructorArguments)
), 0, $previous);
}

}

/**
* @param array<string,mixed> $constructorArguments
*/
private static function formatConstructorArguments(array $constructorArguments): string
{
$formattedArguments = [];
/** @var mixed $argumentValue */
foreach ($constructorArguments as $argumentName => $argumentValue) {
$formattedArguments[] = sprintf('%s: %s', $argumentName, \get_debug_type($argumentValue));
}

return implode(', ', $formattedArguments);
}
}
25 changes: 25 additions & 0 deletions src/Exception/InvalidClassException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?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\Exception;

use Rekalogika\Mapper\Util\TypeUtil;
use Symfony\Component\PropertyInfo\Type;

class InvalidClassException extends UnexpectedValueException
{
public function __construct(Type $type)
{
parent::__construct(sprintf('Trying to map to class "%s", but this is not a valid class, interface, or enum.', TypeUtil::getDebugType($type)));
}
}
137 changes: 101 additions & 36 deletions src/Transformer/ObjectToObjectTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@
use Rekalogika\Mapper\Contracts\TransformerInterface;
use Rekalogika\Mapper\Contracts\TypeMapping;
use Rekalogika\Mapper\Exception\CachedTargetObjectNotFoundException;
use Rekalogika\Mapper\Exception\ClassNotInstantiableException;
use Rekalogika\Mapper\Exception\IncompleteConstructorArgument;
use Rekalogika\Mapper\Exception\InstantiationFailureException;
use Rekalogika\Mapper\Exception\InvalidArgumentException;
use Rekalogika\Mapper\Exception\InvalidClassException;
use Rekalogika\Mapper\MainTransformer;
use Rekalogika\Mapper\ObjectCache\ObjectCache;
use Rekalogika\Mapper\ObjectCache\ObjectCacheFactoryInterface;
use Rekalogika\Mapper\TypeResolver\TypeResolverInterface;
use Rekalogika\Mapper\Util\TypeCheck;
use Rekalogika\Mapper\Util\TypeFactory;
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface;
Expand Down Expand Up @@ -91,17 +96,11 @@ public function transform(
return $source;
}

// list properties

$sourceProperties = $this->listSourceAttributes($sourceType, $context);
$writableTargetProperties = $this
->listTargetWritableAttributes($targetType, $context);

// initialize target, add to cache after initialization

if (null === $target) {
$objectCache->preCache($source, $targetType);
$target = $this->initialize($targetType);
$target = $this->instantiateTarget($source, $targetType, $context);
} else {
if (!is_object($target)) {
throw new InvalidArgumentException(sprintf('The target must be an object, "%s" given.', get_debug_type($target)));
Expand All @@ -110,64 +109,130 @@ public function transform(

$objectCache->saveTarget($source, $targetType, $target);

// list properties

$sourceProperties = $this->listSourceAttributes($sourceType, $context);
$writableTargetProperties = $this
->listTargetWritableAttributes($targetType, $context);

// calculate applicable properties

$propertiesToMap = array_intersect($sourceProperties, $writableTargetProperties);

// map properties

foreach ($propertiesToMap as $property) {
/** @var array<int,Type>|null */
$targetPropertyTypes = $this->propertyTypeExtractor->getTypes($targetClass, $property, $context);
foreach ($propertiesToMap as $propertyName) {
assert(is_object($target));

if (null === $targetPropertyTypes || count($targetPropertyTypes) === 0) {
throw new InvalidArgumentException(sprintf('Cannot get type of target property "%s::$%s".', $targetClass, $property));
}

/** @var mixed */
$sourcePropertyValue = $this->propertyAccessor->getValue($source, $property);
/** @var mixed */
$targetPropertyValue = $this->propertyAccessor->getValue($target, $property);

/** @var mixed */
$targetPropertyValue = $this->mainTransformer?->transform(
source: $sourcePropertyValue,
target: $targetPropertyValue,
targetType: $targetPropertyTypes,
$targetPropertyValue = $this->resolveTargetPropertyValue(
source: $source,
target: $target,
propertyName: $propertyName,
targetClass: $targetClass,
context: $context
);

$this->propertyAccessor->setValue($target, $property, $targetPropertyValue);
$this->propertyAccessor->setValue($target, $propertyName, $targetPropertyValue);
}

return $target;
}

/**
* @param class-string $targetClass
* @param array<string,mixed> $context
* @return mixed
*/
private function resolveTargetPropertyValue(
object $source,
?object $target,
string $propertyName,
string $targetClass,
array $context,
): mixed {
/** @var array<int,Type>|null */
$targetPropertyTypes = $this->propertyTypeExtractor->getTypes($targetClass, $propertyName, $context);

if (null === $targetPropertyTypes || count($targetPropertyTypes) === 0) {
throw new InvalidArgumentException(sprintf('Cannot get type of target property "%s::$%s".', $targetClass, $propertyName));
}

/** @var mixed */
$sourcePropertyValue = $this->propertyAccessor->getValue($source, $propertyName);

if ($target !== null) {
/** @var mixed */
$targetPropertyValue = $this->propertyAccessor->getValue($target, $propertyName);
} else {
$targetPropertyValue = null;
}

/** @var mixed */
$targetPropertyValue = $this->mainTransformer?->transform(
source: $sourcePropertyValue,
target: $targetPropertyValue,
targetType: $targetPropertyTypes,
context: $context
);

return $targetPropertyValue;
}

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

/**
* @param array<string,mixed> $context
* @todo support constructor initialization
*/
protected function initialize(Type $targetType): object
{
$class = $targetType->getClassName();
protected function instantiateTarget(
object $source,
Type $targetType,
array $context
): object {
$targetClass = $targetType->getClassName();

if (null === $class || !\class_exists($class)) {
throw new InvalidArgumentException('Cannot get class name from target type.');
if (null === $targetClass || !\class_exists($targetClass)) {
throw new InvalidClassException($targetType);
}

// $initializableTargetProperties = $this->listTargetInitializableAttributes($targetClass);
$reflectionClass = new \ReflectionClass($targetClass);

// $writableAndNotInitializableTargetProperties = array_diff(
// $writableTargetProperties,
// $initializableTargetProperties
// );
if (!$reflectionClass->isInstantiable()) {
throw new ClassNotInstantiableException($targetClass);
}

return (new \ReflectionClass($class))
->newInstanceWithoutConstructor();
$initializableTargetProperties = $this
->listTargetInitializableAttributes($targetClass, $context);

$constructorArguments = [];

foreach ($initializableTargetProperties as $propertyName) {
try {
/** @var mixed */
$targetPropertyValue = $this->resolveTargetPropertyValue(
source: $source,
target: null,
propertyName: $propertyName,
targetClass: $targetClass,
context: $context
);
} catch (NoSuchPropertyException $e) {
throw new IncompleteConstructorArgument($source, $targetClass, $propertyName, $e);
}

/** @psalm-suppress MixedAssignment */
$constructorArguments[$propertyName] = $targetPropertyValue;
}

try {
return $reflectionClass->newInstanceArgs($constructorArguments);
} catch (\TypeError $e) {
throw new InstantiationFailureException($source, $targetClass, $constructorArguments, $e);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?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\Tests\Fixtures\Constructor;

class ObjectWithConstructorAndMoreArgumentDto
{
public function __construct(
private int $a,
private string $b,
private bool $c,
private float $d,
// uses object to prevent casting
private \Stringable $e,
) {
}

public function getA(): int
{
return $this->a;
}

public function getB(): string
{
return $this->b;
}

public function isC(): bool
{
return $this->c;
}

public function getD(): float
{
return $this->d;
}

// work around mapping null to object, now property info sees it as a string
public function getE(): string
{
return (string) $this->e;
}
}
Loading

0 comments on commit 53b5788

Please sign in to comment.