Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\ObjectMapper\Attribute;

/**
* Applies a transformer to all properties of a class.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
final class TransformAllProperties
{
/**
* @param (string|callable(mixed, object): mixed)|(string|callable(mixed, object): mixed)[]|null $transform A service id or a callable that transforms the value during mapping
*/
public function __construct(
public mixed $transform,
) {
}
}
5 changes: 5 additions & 0 deletions src/Symfony/Component/ObjectMapper/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ CHANGELOG
* The component is not marked as `@experimental` anymore
* Add `ObjectMapperAwareInterface` to set the owning object mapper instance
* Add a `MapCollection` transform that calls the Mapper over iterable properties
* Add a `DepthAwareInterface` to gather information about the mapping depth
* Add a `TransformAllProperties` that applies a transform on all properties of
a mapped object
* Add a `UninitializeProxy` transform that skips proxy initialization for a
given depth

7.3
---
Expand Down
22 changes: 22 additions & 0 deletions src/Symfony/Component/ObjectMapper/DepthAwareInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\ObjectMapper;

/**
* Allows a callable or mapper to be aware of the current mapping depth.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
interface DepthAwareInterface
{
public function setDepth(int $depth): void;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Component\ObjectMapper\Metadata;

use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\ObjectMapper\Attribute\TransformAllProperties;
use Symfony\Component\ObjectMapper\Exception\MappingException;

/**
Expand All @@ -23,6 +24,8 @@ final class ReflectionObjectMapperMetadataFactory implements ObjectMapperMetadat
{
private array $reflectionClassCache = [];
private array $attributesCache = [];
/** @var array<class-string, list<string|callable>> */
private array $classAttributesCache = [];

public function create(object $object, ?string $property = null, array $context = []): array
{
Expand All @@ -35,15 +38,58 @@ public function create(object $object, ?string $property = null, array $context

$refl = $this->reflectionClassCache[$object::class] ??= new \ReflectionClass($object);
$attributes = ($property ? $refl->getProperty($property) : $refl)->getAttributes(Map::class, \ReflectionAttribute::IS_INSTANCEOF);

$globalTransforms = null !== $property ? $this->getClassPropertyTransforms($refl) : [];
$mappings = [];

foreach ($attributes as $attribute) {
$map = $attribute->newInstance();
$mappings[] = new Mapping($map->target, $map->source, $map->if, $map->transform);
$transforms = $map->transform ?? [];
if ($transforms && \is_callable($transforms) || !\is_array($transforms)) {
$transforms = [$transforms];
}

$mappings[] = new Mapping($map->target, $map->source, $map->if, [...$globalTransforms, ...$transforms]);
}

if ($globalTransforms && !$mappings) {
$mappings[] = new Mapping(null, null, null, $globalTransforms);
}

return $this->attributesCache[$key] = $mappings;
} catch (\ReflectionException $e) {
throw new MappingException($e->getMessage(), $e->getCode(), $e);
}
}

/**
* @return list<string|callable>
*/
private function getClassPropertyTransforms(\ReflectionClass $refl): array
{
$key = $refl->getName();
if (isset($this->classAttributesCache[$key])) {
return $this->classAttributesCache[$key];
}

$attributes = $refl->getAttributes(TransformAllProperties::class, \ReflectionAttribute::IS_INSTANCEOF);
if (!$attributes) {
return $this->classAttributesCache[$key] = [];
}

$globalTransforms = [];
foreach ($attributes as $attribute) {
if ($t = $attribute->newInstance()->transform ?? []) {
if (\is_callable($t) || !\is_array($t)) {
$t = [$t];
}

foreach ($t as $transform) {
$globalTransforms[] = $transform;
}
}
}

return $this->classAttributesCache[$key] = $globalTransforms;
}
}
25 changes: 22 additions & 3 deletions src/Symfony/Component/ObjectMapper/ObjectMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
final class ObjectMapper implements ObjectMapperInterface, ObjectMapperAwareInterface
final class ObjectMapper implements ObjectMapperInterface, ObjectMapperAwareInterface, DepthAwareInterface
{
/**
* Tracks recursive references.
*/
private ?\SplObjectStorage $objectMap = null;
private int $depth = 0;

public function __construct(
private readonly ObjectMapperMetadataFactoryInterface $metadataFactory = new ReflectionObjectMapperMetadataFactory(),
Expand All @@ -43,6 +44,11 @@ public function __construct(
) {
}

public function setDepth(int $depth): void
{
$this->depth = $depth;
}

public function map(object $source, object|string|null $target = null): object
{
$objectMapInitialized = false;
Expand Down Expand Up @@ -237,7 +243,11 @@ private function getSourceValue(object $source, object $target, mixed $value, \S
} elseif ($objectMap->offsetExists($value)) {
$value = $objectMap[$value];
} else {
$value = ($this->objectMapper ?? $this)->map($value, $mapTo->target);
$mapper = $this->objectMapper ?? $this;
if ($mapper instanceof DepthAwareInterface) {
$mapper->setDepth($this->depth + 1);
}
$value = $mapper->map($value, $mapTo->target);
}
}

Expand Down Expand Up @@ -317,11 +327,20 @@ private function applyTransforms(Mapping $map, mixed $value, object $source, ?ob
private function getCallable(string|callable $fn, ?ContainerInterface $locator = null): ?callable
{
if (\is_callable($fn)) {
if ($fn instanceof DepthAwareInterface) {
$fn->setDepth($this->depth);
}

return $fn;
}

if ($locator?->has($fn)) {
return $locator->get($fn);
$callable = $locator->get($fn);
if ($callable instanceof DepthAwareInterface) {
$callable->setDepth($this->depth);
}

return $callable;
}

return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\ObjectMapper\Tests\Fixtures\DepthProxy;

use Symfony\Component\VarExporter\LazyObjectInterface;

class MockLazyProxy implements LazyObjectInterface
{
private bool $initialized = false;

public function __construct(
private string $exceptionMessage,
) {
}

public function initializeLazyObject(): object
{
$this->initialized = true;
throw new \RuntimeException($this->exceptionMessage);
}

public function isLazyObjectInitialized(bool $partial = false): bool
{
return $this->initialized;
}

public function resetLazyObject(): bool
{
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Symfony\Component\ObjectMapper\Tests\Fixtures\DepthProxy;

use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\ObjectMapper\Attribute\TransformAllProperties;
use Symfony\Component\ObjectMapper\Transform\UninitializeProxy;

#[Map(target: PostDto::class)]
#[TransformAllProperties(transform: new UninitializeProxy(maxDepth: 1))]
class Post
{
public function __construct(
public string $title,
public object $comments, // This will be the lazy proxy
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Symfony\Component\ObjectMapper\Tests\Fixtures\DepthProxy;

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

class PostDto
{
public string $title;
public ?object $comments = null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\ObjectMapper\Tests\Fixtures\DepthProxy;

use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\ObjectMapper\Attribute\TransformAllProperties;
use Symfony\Component\ObjectMapper\Transform\UninitializeProxy;

#[Map(target: UserDto::class)]
#[TransformAllProperties(transform: new UninitializeProxy(maxDepth: 1))]
class User
{
public function __construct(
public string $name,
public object $post,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\ObjectMapper\Tests\Fixtures\DepthProxy;

class UserDto
{
public string $name;
public ?PostDto $post = null;
}
24 changes: 24 additions & 0 deletions src/Symfony/Component/ObjectMapper/Tests/ObjectMapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
use Symfony\Component\ObjectMapper\Tests\Fixtures\DeeperRecursion\Relation;
use Symfony\Component\ObjectMapper\Tests\Fixtures\DeeperRecursion\RelationDto;
use Symfony\Component\ObjectMapper\Tests\Fixtures\DefaultValueStdClass\TargetDto;
use Symfony\Component\ObjectMapper\Tests\Fixtures\DepthProxy;
use Symfony\Component\ObjectMapper\Tests\Fixtures\Flatten\TargetUser;
use Symfony\Component\ObjectMapper\Tests\Fixtures\Flatten\User;
use Symfony\Component\ObjectMapper\Tests\Fixtures\Flatten\UserProfile;
Expand Down Expand Up @@ -563,4 +564,27 @@ public function testTransformCollection()

$this->assertEquals([new TransformCollectionD('a'), new TransformCollectionD('b')], $transformed->foo);
}

public function testTransformAllPropertiesWithDepthStopsRecursion()
{
$user = new DepthProxy\User(
'User',
new DepthProxy\Post(
'Post',
new DepthProxy\MockLazyProxy('Proxy (Comment) should not be initialized')
)
);

$container = $this->createMock(ContainerInterface::class);

$mapper = new ObjectMapper(transformCallableLocator: $container);
$dto = $mapper->map($user, DepthProxy\UserDto::class);

$this->assertInstanceOf(DepthProxy\UserDto::class, $dto);
$this->assertSame('User', $dto->name);
$this->assertInstanceOf(DepthProxy\PostDto::class, $dto->post);
$this->assertSame('Post', $dto->post->title);

$this->assertNull($dto->post->comments);
}
}
Loading