Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[LiveComponent] Add support for Doctrine ArrayCollections Hydration #1354

Closed
wants to merge 21 commits into from
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
1 change: 1 addition & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 2.14.0

- Add support for URL binding in `LiveProp`
- Add support for Doctrine ArrayCollection (de)Hydration.

## 2.13.2

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\UX\LiveComponent\Hydration\DoctrineArrayCollectionHydrationExtension;
use Symfony\UX\LiveComponent\Hydration\DoctrineEntityHydrationExtension;
use Symfony\UX\LiveComponent\LiveComponentBundle;

Expand All @@ -34,6 +35,11 @@ public function process(ContainerBuilder $container): void
->setArguments([new IteratorArgument([new Reference('doctrine')])]) // TODO: add support for multiple entity managers
->addTag(LiveComponentBundle::HYDRATION_EXTENSION_TAG)
;

$container->register('ux.live_component.doctrine_array_collection_hydration_extension', DoctrineArrayCollectionHydrationExtension::class)
->setArguments([new IteratorArgument([new Reference('doctrine')])]) // TODO: add support for multiple entity managers
->addTag(LiveComponentBundle::HYDRATION_EXTENSION_TAG)
;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

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

namespace Symfony\UX\LiveComponent\Hydration;

use Doctrine\Common\Collections\ArrayCollection;

/**
* @author Jean-Paul van der Wegen <[email protected]>
*
* @experimental
*
* @internal
*/
class DoctrineArrayCollectionHydrationExtension implements HydrationExtensionInterface
{
use DoctrineHydrationTrait;

public function __construct(protected readonly iterable $managerRegistries)
{
}

public function supports(string $className): bool
{
return ArrayCollection::class === $className;
}

public function hydrate(mixed $value, string $className): ?object
{
if (!\is_array($value)) {
throw new \InvalidArgumentException(sprintf('Cannot hydrate ArrayCollection. Value must be an array, %s given.', \gettype($value)));
}

$output = new ArrayCollection();
foreach ($value as $item) {
$this->validateHydrateItem($item);

$output->add($this->getObject($item['className'], $item['identifierValue']));
}

return $output;
}

private function validateHydrateItem(array $item): void
{
$requiredKeys = ['className', 'identifierValue'];

foreach ($requiredKeys as $key) {
if (!isset($item[$key])) {
throw new \InvalidArgumentException(sprintf('Cannot hydrate ArrayCollection: key "%s" is missing', $key));
}
}
}

public function dehydrate(object $object): mixed
{
$output = [];
foreach ($object as $entityObject) {
$identifierValue = $this->getIdentifierValue($entityObject);

if (empty($identifierValue)) {
throw new \InvalidArgumentException(sprintf('Cannot dehydrate ArrayCollection that contains a non-persisted entity "%s".', $entityObject::class));
}

$output[] = [
'className' => $entityObject::class,
'identifierValue' => $identifierValue,
];
}

return $output;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,12 @@

namespace Symfony\UX\LiveComponent\Hydration;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager;

class DoctrineEntityHydrationExtension implements HydrationExtensionInterface
{
/**
* @param ManagerRegistry[] $managerRegistries
*/
public function __construct(
private iterable $managerRegistries,
) {
use DoctrineHydrationTrait;

public function __construct(protected readonly iterable $managerRegistries)
{
}

public function supports(string $className): bool
Expand Down Expand Up @@ -53,56 +47,6 @@ public function hydrate(mixed $value, string $className): ?object

public function dehydrate(object $object): mixed
{
$id = $this
->objectManagerFor($class = $object::class)
->getClassMetadata($class)
->getIdentifierValues($object)
;

switch (\count($id)) {
case 0:
// a non-persisted entity
return [];
case 1:
return array_values($id)[0];
}

// composite id
return $id;
}

private function objectManagerFor(string $class): ?ObjectManager
{
if (!class_exists($class)) {
return null;
}

// todo cache/warmup an array of classes that are "doctrine objects"
foreach ($this->managerRegistries as $registry) {
if ($om = $registry->getManagerForClass($class)) {
return self::ensureManagedObject($om, $class);
}
}

return null;
}

/**
* Ensure the $class is not embedded or a mapped superclass.
*/
private static function ensureManagedObject(ObjectManager $om, string $class): ?ObjectManager
{
if (!$om instanceof EntityManagerInterface) {
// todo might need to add some checks once ODM support is added
return $om;
}

$metadata = $om->getClassMetadata($class);

if ($metadata->isEmbeddedClass || $metadata->isMappedSuperclass) {
return null;
}

return $om;
return $this->getIdentifierValue($object);
}
}
94 changes: 94 additions & 0 deletions src/LiveComponent/src/Hydration/DoctrineHydrationTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

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

namespace Symfony\UX\LiveComponent\Hydration;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\Persistence\ObjectManager;

/**
* @internal
*/
trait DoctrineHydrationTrait
{
private function objectManagerFor(string $className): ?ObjectManager
{
if (!class_exists($className)) {
return null;
}

// todo cache/warmup an array of classes that are "doctrine objects"
foreach ($this->managerRegistries as $registry) {
if ($om = $registry->getManagerForClass($className)) {
return self::ensureManagedObject($om, $className);
}
}

return null;
}

/**
* Ensure the $class is not embedded or a mapped superclass.
*/
private static function ensureManagedObject(ObjectManager $om, string $className): ?ObjectManager
{
if (!$om instanceof EntityManagerInterface) {
// todo might need to add some checks once ODM support is added
return $om;
}

$metadata = $om->getClassMetadata($className);

if ($metadata->isEmbeddedClass || $metadata->isMappedSuperclass) {
return null;
}

return $om;
}

/**
* @template T of object
*
* @param class-string<T> $className
*
* @throws EntityNotFoundException
*/
protected function getObject(string $className, mixed $id): object
{
$object = $this->objectManagerFor($className)->find($className, $id);
if (!$object instanceof $className) {
throw new EntityNotFoundException(sprintf('Cannot find entity "%s" with id "%s".', $className, $id));
}

return $object;
}

private function getIdentifierValue(object $object): mixed
{
$id = $this
->objectManagerFor($className = $object::class)
->getClassMetadata($className)
->getIdentifierValues($object)
;

switch (\count($id)) {
case 0:
// a non-persisted entity
return [];
case 1:
return array_values($id)[0];
}

// composite id
return $id;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace Symfony\UX\LiveComponent\Tests\Fixtures\Dto;

use Doctrine\Common\Collections\ArrayCollection;
use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\ProductFixtureEntity;

class HoldsArrayCollectionAndEntity
{
public ?ProductFixtureEntity $product = null;
public ArrayCollection $productList;

public function __construct()
{
$this->productList = new ArrayCollection();
}

public function getProduct(): ?ProductFixtureEntity
{
return $this->product;
}

public function setProduct(?ProductFixtureEntity $product): void
{
$this->product = $product;
}

public function getProductList(): ArrayCollection
{
return $this->productList;
}

public function setProductList(ArrayCollection $productList): void
{
$this->productList = $productList;
}
}
Loading