Skip to content
Open
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
137 changes: 137 additions & 0 deletions src/Doctrine/Orm/State/ManagedEntityTransform.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Orm\State;

use ApiPlatform\Metadata\Exception\ExceptionInterface as MetadataExceptionInterface;
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\Util\StateOptionsTrait;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\ObjectMapper\TransformCallableInterface;

/**
* Resolves a related API Resource to the managed Doctrine object it stands for.
*
* The object mapper builds objects and has no identity map. When a resource declares its
* mapping in the read direction only — `#[Map(source: Entity::class)]` on the resource,
* which is what keeps the entity free of any presentation concern — a relation typed on
* another resource is never converted, and lands on the entity as-is:
*
* Expected argument of type "?Author", "AuthorResource" given at property path "author"
*
* Declaring the reverse mapping is not enough either: the mapper would then build a fresh
* entity from the resource's scalars — right identifier, an instance Doctrine has never
* seen — and the flush raises "A new entity was found through the relationship".
* Cascading it inserts a duplicate row instead.
*
* Nothing has to be declared per relation: the managed class is read from the related
* resource's state options, and the identifiers from its metadata — never assumed to be
* called `id`, since a resource keyed on a natural code is just as valid.
*
* #[Map(target: 'author', transform: ManagedEntityTransform::class)]
* public ?AuthorResource $author = null;
*
* A to-many arrives as an iterable of resources and every item is resolved, which is what
* `MapCollection` needs on the write side.
*
* @implements TransformCallableInterface<object, object>
*
* @experimental
*/
final class ManagedEntityTransform implements TransformCallableInterface
{
use StateOptionsTrait;

/** @var array<class-string, class-string|false> */
private array $managedClasses = [];

public function __construct(
private readonly ManagerRegistry $managerRegistry,
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
private readonly IdentifiersExtractorInterface $identifiersExtractor,
) {
}

public function __invoke(mixed $value, object $source, ?object $target): mixed
{
if (is_iterable($value)) {
$resolved = [];

foreach ($value as $key => $item) {
$resolved[$key] = $this->resolve($item);
}

return $resolved;
}

return $this->resolve($value);
}

private function resolve(mixed $value): mixed
{
if (!\is_object($value) || null === ($class = $this->managedClass($value::class))) {
return $value;
}

if (!$manager = $this->managerRegistry->getManagerForClass($class)) {
return $value;
}

try {
$identifiers = $this->identifiersExtractor->getIdentifiersFromItem($value);
} catch (MetadataExceptionInterface) {
return $value;
}

// A resource without a complete identifier stands for no row: hand it back untouched
// rather than guessing, and let the caller deal with an unresolved relation.
if (!$identifiers || \count($identifiers) !== \count(array_filter($identifiers, static fn (mixed $identifier): bool => null !== $identifier))) {
return $value;
}

return $manager->find($class, 1 === \count($identifiers) ? current($identifiers) : $identifiers) ?? $value;
}

/**
* @param class-string $resourceClass
*
* @return class-string|null
*/
private function managedClass(string $resourceClass): ?string
{
if (isset($this->managedClasses[$resourceClass])) {
return $this->managedClasses[$resourceClass] ?: null;
}

try {
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass);
} catch (MetadataExceptionInterface) {
$this->managedClasses[$resourceClass] = false;

return null;
}

foreach ($resourceMetadataCollection as $resourceMetadata) {
foreach ($resourceMetadata->getOperations() ?? [] as $operation) {
if ($class = $this->getStateOptionsClass($operation)) {
return $this->managedClasses[$resourceClass] = $class;
}
}
}

$this->managedClasses[$resourceClass] = false;

return null;
}
}
161 changes: 161 additions & 0 deletions src/Doctrine/Orm/Tests/State/ManagedEntityTransformTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Orm\Tests\State;

use ApiPlatform\Doctrine\Orm\State\ManagedEntityTransform;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;

class ManagedEntityTransformTest extends TestCase
{
use ProphecyTrait;

public function testItResolvesTheRelatedResourceToItsManagedEntity(): void
{
$resource = new ManagedEntityTransformTestResource();
$resource->id = 1;
$entity = new Dummy();

$objectManagerProphecy = $this->prophesize(ObjectManager::class);
$objectManagerProphecy->find(Dummy::class, 1)->willReturn($entity)->shouldBeCalled();

$transform = $this->transform($objectManagerProphecy->reveal(), ['id' => 1]);

$this->assertSame($entity, $transform($resource, new \stdClass(), null));
}

/**
* A to-many arrives as an iterable of resources; every item needs resolving, or Doctrine is
* handed resource objects for an association.
*/
public function testItResolvesEveryItemOfACollection(): void
{
$resource = new ManagedEntityTransformTestResource();
$resource->id = 1;
$entity = new Dummy();

$objectManagerProphecy = $this->prophesize(ObjectManager::class);
$objectManagerProphecy->find(Dummy::class, 1)->willReturn($entity);

$transform = $this->transform($objectManagerProphecy->reveal(), ['id' => 1]);

$this->assertSame([$entity, $entity], $transform([$resource, $resource], new \stdClass(), null));
}

/**
* The identifier is not assumed to be called `id`: a resource keyed on a natural code is just
* as valid, and hardcoding `id` would silently resolve nothing there.
*/
public function testItReadsAnIdentifierThatIsNotCalledId(): void
{
$resource = new ManagedEntityTransformTestResource();
$entity = new Dummy();

$objectManagerProphecy = $this->prophesize(ObjectManager::class);
$objectManagerProphecy->find(Dummy::class, 'FR')->willReturn($entity)->shouldBeCalled();

$transform = $this->transform($objectManagerProphecy->reveal(), ['code' => 'FR']);

$this->assertSame($entity, $transform($resource, new \stdClass(), null));
}

public function testItLeavesAValueThatIsNotAResourceUntouched(): void
{
$value = new \stdClass();

$managerRegistryProphecy = $this->prophesize(ManagerRegistry::class);
$managerRegistryProphecy->getManagerForClass(Argument::any())->shouldNotBeCalled();

$resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
$resourceMetadataCollectionFactoryProphecy->create(\stdClass::class)->willThrow(new ResourceClassNotFoundException());

$transform = new ManagedEntityTransform(
$managerRegistryProphecy->reveal(),
$resourceMetadataCollectionFactoryProphecy->reveal(),
$this->prophesize(IdentifiersExtractorInterface::class)->reveal(),
);

$this->assertSame($value, $transform($value, new \stdClass(), null));
}

/**
* A resource without a complete identifier stands for no row: hand it back rather than guess.
*/
public function testItLeavesAResourceWithoutIdentifierUntouched(): void
{
$resource = new ManagedEntityTransformTestResource();

$objectManagerProphecy = $this->prophesize(ObjectManager::class);
$objectManagerProphecy->find(Argument::cetera())->shouldNotBeCalled();

$transform = $this->transform($objectManagerProphecy->reveal(), ['id' => null]);

$this->assertSame($resource, $transform($resource, new \stdClass(), null));
}

public function testItLeavesAScalarUntouched(): void
{
$transform = new ManagedEntityTransform(
$this->prophesize(ManagerRegistry::class)->reveal(),
$this->prophesize(ResourceMetadataCollectionFactoryInterface::class)->reveal(),
$this->prophesize(IdentifiersExtractorInterface::class)->reveal(),
);

$this->assertSame('a string', $transform('a string', new \stdClass(), null));
}

/**
* @param array<string, mixed> $identifiers
*/
private function transform(ObjectManager $objectManager, array $identifiers): ManagedEntityTransform
{
$managerRegistryProphecy = $this->prophesize(ManagerRegistry::class);
$managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($objectManager);

$resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
$resourceMetadataCollectionFactoryProphecy->create(ManagedEntityTransformTestResource::class)->willReturn(
new ResourceMetadataCollection(ManagedEntityTransformTestResource::class, [
(new ApiResource())->withOperations(new Operations([
'get' => (new Get())->withStateOptions(new Options(entityClass: Dummy::class)),
])),
])
);

$identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class);
$identifiersExtractorProphecy->getIdentifiersFromItem(Argument::type(ManagedEntityTransformTestResource::class))->willReturn($identifiers);

return new ManagedEntityTransform(
$managerRegistryProphecy->reveal(),
$resourceMetadataCollectionFactoryProphecy->reveal(),
$identifiersExtractorProphecy->reveal(),
);
}
}

class ManagedEntityTransformTestResource
{
public ?int $id = null;
}
6 changes: 5 additions & 1 deletion src/Doctrine/Orm/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"ramsey/uuid-doctrine": "^2.0",
"symfony/cache": "^7.4 || ^8.0",
"symfony/framework-bundle": "^7.4 || ^8.0",
"symfony/object-mapper": "^7.4 || ^8.0",
"symfony/property-access": "^7.4 || ^8.0",
"symfony/serializer": "^7.4 || ^8.0",
"symfony/property-info": "^7.4 || ^8.0",
Expand Down Expand Up @@ -80,5 +81,8 @@
"test": "./vendor/bin/phpunit"
},
"minimum-stability": "beta",
"prefer-stable": true
"prefer-stable": true,
"conflict": {
"symfony/object-mapper": "<7.3.4"
}
}
16 changes: 16 additions & 0 deletions src/Symfony/Bundle/Resources/config/doctrine_orm.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use ApiPlatform\Doctrine\Common\Metadata\Property\DoctrineDiscriminatorSerializerPropertyMetadataFactory;
use ApiPlatform\Doctrine\Orm\State\ManagedEntityTransform;
use ApiPlatform\Doctrine\Common\State\PersistProcessor;
use ApiPlatform\Doctrine\Common\State\RemoveProcessor;
use ApiPlatform\Doctrine\Orm\Extension\EagerLoadingExtension;
Expand Down Expand Up @@ -42,6 +43,7 @@
use ApiPlatform\Doctrine\Orm\State\ItemProvider;
use ApiPlatform\Doctrine\Orm\State\LinksHandler;
use Doctrine\Persistence\Mapping\ClassMetadataFactory;
use Symfony\Component\ObjectMapper\TransformCallableInterface;

return function (ContainerConfigurator $container) {
$services = $container->services();
Expand Down Expand Up @@ -302,4 +304,18 @@
service('doctrine'),
])
->tag('api_platform.doctrine.orm.links_handler', ['key' => 'api_platform.doctrine.orm.links_handler']);

if (interface_exists(TransformCallableInterface::class)) {
// Registered under its own class name: the object mapper resolves a `transform` by the
// string it is given, and that is the class name users write in the attribute.
$services->set(ManagedEntityTransform::class)
->args([
service('doctrine'),
service('api_platform.metadata.resource.metadata_collection_factory'),
service('api_platform.api.identifiers_extractor'),
])
->tag('object_mapper.transform_callable');

$services->alias('api_platform.doctrine.orm.object_mapper.managed_entity_transform', ManagedEntityTransform::class);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ManagedRelation;

use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ManagedRelation\ManagedRelationAuthor;
use Symfony\Component\ObjectMapper\Attribute\Map;

#[ApiResource(
operations: [
new Get(),
new Post(),
],
shortName: 'ManagedRelationAuthor',
stateOptions: new Options(entityClass: ManagedRelationAuthor::class)
)]
#[Map(source: ManagedRelationAuthor::class)]
class ManagedRelationAuthorResource
{
public ?int $id = null;

public string $name = '';
}
Loading
Loading