This commit is contained in:
Marek
2026-03-24 00:04:55 +01:00
commit c5229e48ed
4225 changed files with 511461 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
<?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\Bridge\Doctrine\Form\ChoiceList;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\Form\ChoiceList\Loader\AbstractChoiceLoader;
use Symfony\Component\Form\Exception\LogicException;
/**
* Loads choices using a Doctrine object manager.
*
* @author Bernhard Schussek <bschussek@gmail.com>
*/
class DoctrineChoiceLoader extends AbstractChoiceLoader
{
/** @var class-string */
private readonly string $class;
/**
* Creates a new choice loader.
*
* Optionally, an implementation of {@link EntityLoaderInterface} can be
* passed which optimizes the object loading for one of the Doctrine
* mapper implementations.
*
* @param string $class The class name of the loaded objects
*/
public function __construct(
private readonly ObjectManager $manager,
string $class,
private readonly ?IdReader $idReader = null,
private readonly ?EntityLoaderInterface $objectLoader = null,
) {
if ($idReader && !$idReader->isSingleId()) {
throw new \InvalidArgumentException(\sprintf('The "$idReader" argument of "%s" must be null when the query cannot be optimized because of composite id fields.', __METHOD__));
}
$this->class = $manager->getClassMetadata($class)->getName();
}
protected function loadChoices(): iterable
{
return $this->objectLoader
? $this->objectLoader->getEntities()
: $this->manager->getRepository($this->class)->findAll();
}
protected function doLoadValuesForChoices(array $choices): array
{
// Optimize performance for single-field identifiers. We already
// know that the IDs are used as values
// Attention: This optimization does not check choices for existence
if ($this->idReader) {
throw new LogicException('Not defining the IdReader explicitly as a value callback when the query can be optimized is not supported.');
}
return parent::doLoadValuesForChoices($choices);
}
protected function doLoadChoicesForValues(array $values, ?callable $value): array
{
if ($this->idReader && null === $value) {
throw new LogicException('Not defining the IdReader explicitly as a value callback when the query can be optimized is not supported.');
}
$idReader = null;
if (\is_array($value) && $value[0] instanceof IdReader) {
$idReader = $value[0];
} elseif ($value instanceof \Closure && ($rThis = (new \ReflectionFunction($value))->getClosureThis()) instanceof IdReader) {
$idReader = $rThis;
}
// Optimize performance in case we have an object loader and
// a single-field identifier
if ($idReader && $this->objectLoader) {
$objects = [];
$objectsById = [];
// Maintain order and indices from the given $values
// An alternative approach to the following loop is to add the
// "INDEX BY" clause to the Doctrine query in the loader,
// but I'm not sure whether that's doable in a generic fashion.
foreach ($this->objectLoader->getEntitiesByIds($idReader->getIdField(), $values) as $object) {
$objectsById[$idReader->getIdValue($object)] = $object;
}
foreach ($values as $i => $id) {
if (isset($objectsById[$id])) {
$objects[$i] = $objectsById[$id];
}
}
return $objects;
}
return parent::doLoadChoicesForValues($values, $value);
}
}

View File

@@ -0,0 +1,30 @@
<?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\Bridge\Doctrine\Form\ChoiceList;
/**
* Custom loader for entities in the choice list.
*
* @author Benjamin Eberlei <kontakt@beberlei.de>
*/
interface EntityLoaderInterface
{
/**
* Returns an array of entities that are valid choices in the corresponding choice list.
*/
public function getEntities(): array;
/**
* Returns an array of entities matching the given identifiers.
*/
public function getEntitiesByIds(string $identifier, array $values): array;
}

View File

@@ -0,0 +1,109 @@
<?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\Bridge\Doctrine\Form\ChoiceList;
use Doctrine\Persistence\Mapping\ClassMetadata;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\Form\Exception\RuntimeException;
/**
* A utility for reading object IDs.
*
* @author Bernhard Schussek <bschussek@gmail.com>
*
* @internal
*/
class IdReader
{
private readonly bool $singleId;
private readonly bool $intId;
private readonly string $idField;
private readonly ?self $associationIdReader;
public function __construct(
private readonly ObjectManager $om,
private readonly ClassMetadata $classMetadata,
) {
$ids = $classMetadata->getIdentifierFieldNames();
$idType = $classMetadata->getTypeOfField(current($ids));
$singleId = 1 === \count($ids);
$this->idField = current($ids);
// single field association are resolved, since the schema column could be an int
if ($singleId && $classMetadata->hasAssociation($this->idField)) {
$this->associationIdReader = new self($om, $om->getClassMetadata(
$classMetadata->getAssociationTargetClass($this->idField)
));
$singleId = $this->associationIdReader->isSingleId();
$this->intId = $this->associationIdReader->isIntId();
} else {
$this->intId = $singleId && \in_array($idType, ['integer', 'smallint', 'bigint'], true);
$this->associationIdReader = null;
}
$this->singleId = $singleId;
}
/**
* Returns whether the class has a single-column ID.
*/
public function isSingleId(): bool
{
return $this->singleId;
}
/**
* Returns whether the class has a single-column integer ID.
*/
public function isIntId(): bool
{
return $this->intId;
}
/**
* Returns the ID value for an object.
*
* This method assumes that the object has a single-column ID.
*/
public function getIdValue(?object $object = null): string
{
if (!$object) {
return '';
}
if (!$this->om->contains($object)) {
throw new RuntimeException(\sprintf('Entity of type "%s" passed to the choice field must be managed. Maybe you forget to persist it in the entity manager?', get_debug_type($object)));
}
$this->om->initializeObject($object);
$idValue = current($this->classMetadata->getIdentifierValues($object));
if ($this->associationIdReader) {
$idValue = $this->associationIdReader->getIdValue($idValue);
}
return (string) $idValue;
}
/**
* Returns the name of the ID field.
*
* This method assumes that the object has a single-column ID.
*/
public function getIdField(): string
{
return $this->idField;
}
}

View File

@@ -0,0 +1,102 @@
<?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\Bridge\Doctrine\Form\ChoiceList;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Types\AbstractUidType;
use Symfony\Component\Form\Exception\TransformationFailedException;
/**
* Loads entities using a {@link QueryBuilder} instance.
*
* @author Benjamin Eberlei <kontakt@beberlei.de>
* @author Bernhard Schussek <bschussek@gmail.com>
*/
class ORMQueryBuilderLoader implements EntityLoaderInterface
{
public function __construct(
private readonly QueryBuilder $queryBuilder,
) {
}
public function getEntities(): array
{
return $this->queryBuilder->getQuery()->execute();
}
public function getEntitiesByIds(string $identifier, array $values): array
{
if (null !== $this->queryBuilder->getMaxResults() || 0 < (int) $this->queryBuilder->getFirstResult()) {
// an offset or a limit would apply on results including the where clause with submitted id values
// that could make invalid choices valid
$choices = [];
$metadata = $this->queryBuilder->getEntityManager()->getClassMetadata(current($this->queryBuilder->getRootEntities()));
foreach ($this->getEntities() as $entity) {
if (\in_array((string) current($metadata->getIdentifierValues($entity)), $values, true)) {
$choices[] = $entity;
}
}
return $choices;
}
$qb = clone $this->queryBuilder;
$alias = current($qb->getRootAliases());
$parameter = 'ORMQueryBuilderLoader_getEntitiesByIds_'.$identifier;
$parameter = str_replace('.', '_', $parameter);
$where = $qb->expr()->in($alias.'.'.$identifier, ':'.$parameter);
// Guess type
$entity = current($qb->getRootEntities());
$metadata = $qb->getEntityManager()->getClassMetadata($entity);
if (\in_array($type = $metadata->getTypeOfField($identifier), ['integer', 'bigint', 'smallint'], true)) {
$parameterType = ArrayParameterType::INTEGER;
// Filter out non-integer values (e.g. ""). If we don't, some
// databases such as PostgreSQL fail.
$values = array_values(array_filter($values, static fn ($v) => \is_string($v) && ctype_digit($v) || (string) $v === (string) (int) $v));
} elseif (null !== $type && (\in_array($type, ['ulid', 'uuid', 'guid'], true) || (Type::hasType($type) && is_subclass_of(Type::getType($type), AbstractUidType::class)))) {
$parameterType = ArrayParameterType::STRING;
// Like above, but we just filter out empty strings.
$values = array_values(array_filter($values, fn ($v) => '' !== (string) $v));
// Convert values into right type
if (Type::hasType($type)) {
$doctrineType = Type::getType($type);
$platform = $qb->getEntityManager()->getConnection()->getDatabasePlatform();
foreach ($values as &$value) {
try {
$value = $doctrineType->convertToDatabaseValue($value, $platform);
} catch (ConversionException $e) {
throw new TransformationFailedException(\sprintf('Failed to transform "%s" into "%s".', $value, $type), 0, $e);
}
}
unset($value);
}
} else {
$parameterType = ArrayParameterType::STRING;
}
if (!$values) {
return [];
}
return $qb->andWhere($where)
->getQuery()
->setParameter($parameter, $values, $parameterType)
->getResult();
}
}

View File

@@ -0,0 +1,56 @@
<?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\Bridge\Doctrine\Form\DataTransformer;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ReadableCollection;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
/**
* @author Bernhard Schussek <bschussek@gmail.com>
*
* @implements DataTransformerInterface<Collection|array, array>
*/
class CollectionToArrayTransformer implements DataTransformerInterface
{
public function transform(mixed $collection): mixed
{
if (null === $collection) {
return [];
}
// For cases when the collection getter returns $collection->toArray()
// in order to prevent modifications of the returned collection
if (\is_array($collection)) {
return $collection;
}
if (!$collection instanceof ReadableCollection) {
throw new TransformationFailedException(\sprintf('Expected a "%s" object.', ReadableCollection::class));
}
return $collection->toArray();
}
public function reverseTransform(mixed $array): Collection
{
if ('' === $array || null === $array) {
$array = [];
} else {
$array = (array) $array;
}
return new ArrayCollection($array);
}
}

View File

@@ -0,0 +1,37 @@
<?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\Bridge\Doctrine\Form;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractExtension;
use Symfony\Component\Form\FormTypeGuesserInterface;
class DoctrineOrmExtension extends AbstractExtension
{
public function __construct(
protected ManagerRegistry $registry,
) {
}
protected function loadTypes(): array
{
return [
new EntityType($this->registry),
];
}
protected function loadTypeGuesser(): ?FormTypeGuesserInterface
{
return new DoctrineOrmTypeGuesser($this->registry);
}
}

View File

@@ -0,0 +1,204 @@
<?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\Bridge\Doctrine\Form;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\ClassMetadataInfo;
use Doctrine\ORM\Mapping\FieldMapping;
use Doctrine\ORM\Mapping\JoinColumnMapping;
use Doctrine\ORM\Mapping\MappingException as LegacyMappingException;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\Mapping\MappingException;
use Doctrine\Persistence\Proxy;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\DateIntervalType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TimeType;
use Symfony\Component\Form\FormTypeGuesserInterface;
use Symfony\Component\Form\Guess\Guess;
use Symfony\Component\Form\Guess\TypeGuess;
use Symfony\Component\Form\Guess\ValueGuess;
class DoctrineOrmTypeGuesser implements FormTypeGuesserInterface
{
private array $cache = [];
public function __construct(
protected ManagerRegistry $registry,
) {
}
public function guessType(string $class, string $property): ?TypeGuess
{
if (!$ret = $this->getMetadata($class)) {
return new TypeGuess(TextType::class, [], Guess::LOW_CONFIDENCE);
}
[$metadata, $name] = $ret;
if ($metadata->hasAssociation($property)) {
$multiple = $metadata->isCollectionValuedAssociation($property);
$mapping = $metadata->getAssociationMapping($property);
return new TypeGuess(EntityType::class, ['em' => $name, 'class' => $mapping['targetEntity'], 'multiple' => $multiple], Guess::HIGH_CONFIDENCE);
}
return match ($metadata->getTypeOfField($property)) {
'array', // DBAL < 4
Types::SIMPLE_ARRAY => new TypeGuess(CollectionType::class, [], Guess::MEDIUM_CONFIDENCE),
Types::BOOLEAN => new TypeGuess(CheckboxType::class, [], Guess::HIGH_CONFIDENCE),
Types::DATETIME_MUTABLE,
Types::DATETIMETZ_MUTABLE,
'vardatetime' => new TypeGuess(DateTimeType::class, [], Guess::HIGH_CONFIDENCE),
Types::DATETIME_IMMUTABLE,
Types::DATETIMETZ_IMMUTABLE => new TypeGuess(DateTimeType::class, ['input' => 'datetime_immutable'], Guess::HIGH_CONFIDENCE),
Types::DATEINTERVAL => new TypeGuess(DateIntervalType::class, [], Guess::HIGH_CONFIDENCE),
Types::DATE_MUTABLE => new TypeGuess(DateType::class, [], Guess::HIGH_CONFIDENCE),
Types::DATE_IMMUTABLE => new TypeGuess(DateType::class, ['input' => 'datetime_immutable'], Guess::HIGH_CONFIDENCE),
Types::TIME_MUTABLE => new TypeGuess(TimeType::class, [], Guess::HIGH_CONFIDENCE),
Types::TIME_IMMUTABLE => new TypeGuess(TimeType::class, ['input' => 'datetime_immutable'], Guess::HIGH_CONFIDENCE),
Types::DECIMAL => new TypeGuess(NumberType::class, ['input' => 'string'], Guess::MEDIUM_CONFIDENCE),
Types::FLOAT => new TypeGuess(NumberType::class, [], Guess::MEDIUM_CONFIDENCE),
Types::INTEGER,
Types::BIGINT,
Types::SMALLINT => new TypeGuess(IntegerType::class, [], Guess::MEDIUM_CONFIDENCE),
Types::STRING => new TypeGuess(TextType::class, [], Guess::MEDIUM_CONFIDENCE),
Types::TEXT => new TypeGuess(TextareaType::class, [], Guess::MEDIUM_CONFIDENCE),
default => new TypeGuess(TextType::class, [], Guess::LOW_CONFIDENCE),
};
}
public function guessRequired(string $class, string $property): ?ValueGuess
{
$classMetadatas = $this->getMetadata($class);
if (!$classMetadatas) {
return null;
}
/** @var ClassMetadataInfo $classMetadata */
$classMetadata = $classMetadatas[0];
// Check whether the field exists and is nullable or not
if (isset($classMetadata->fieldMappings[$property])) {
if (!$classMetadata->isNullable($property) && Types::BOOLEAN !== $classMetadata->getTypeOfField($property)) {
return new ValueGuess(true, Guess::HIGH_CONFIDENCE);
}
return new ValueGuess(false, Guess::MEDIUM_CONFIDENCE);
}
// Check whether the association exists, is a to-one association and its
// join column is nullable or not
if ($classMetadata->isAssociationWithSingleJoinColumn($property)) {
$mapping = $classMetadata->getAssociationMapping($property);
if (null === self::getMappingValue($mapping['joinColumns'][0], 'nullable')) {
// The "nullable" option defaults to true, in that case the
// field should not be required.
return new ValueGuess(false, Guess::HIGH_CONFIDENCE);
}
return new ValueGuess(!self::getMappingValue($mapping['joinColumns'][0], 'nullable'), Guess::HIGH_CONFIDENCE);
}
return null;
}
public function guessMaxLength(string $class, string $property): ?ValueGuess
{
$ret = $this->getMetadata($class);
if ($ret && isset($ret[0]->fieldMappings[$property])) {
$mapping = $ret[0]->getFieldMapping($property);
$length = $mapping instanceof FieldMapping ? $mapping->length : ($mapping['length'] ?? null);
if (null !== $length) {
return new ValueGuess($length, Guess::HIGH_CONFIDENCE);
}
if (\in_array($ret[0]->getTypeOfField($property), [Types::DECIMAL, Types::FLOAT], true)) {
return new ValueGuess(null, Guess::MEDIUM_CONFIDENCE);
}
}
return null;
}
public function guessPattern(string $class, string $property): ?ValueGuess
{
$ret = $this->getMetadata($class);
if ($ret && isset($ret[0]->fieldMappings[$property])) {
if (\in_array($ret[0]->getTypeOfField($property), [Types::DECIMAL, Types::FLOAT], true)) {
return new ValueGuess(null, Guess::MEDIUM_CONFIDENCE);
}
}
return null;
}
/**
* @template T of object
*
* @param class-string<T> $class
*
* @return array{0:ClassMetadata<T>, 1:string}|null
*/
protected function getMetadata(string $class): ?array
{
// normalize class name
$class = self::getRealClass(ltrim($class, '\\'));
if (\array_key_exists($class, $this->cache)) {
return $this->cache[$class];
}
$this->cache[$class] = null;
foreach ($this->registry->getManagers() as $name => $em) {
try {
return $this->cache[$class] = [$em->getClassMetadata($class), $name];
} catch (MappingException) {
// not an entity or mapped super class
} catch (LegacyMappingException) {
// not an entity or mapped super class, using Doctrine ORM 2.2
}
}
return null;
}
private static function getRealClass(string $class): string
{
if (false === $pos = strrpos($class, '\\'.Proxy::MARKER.'\\')) {
return $class;
}
return substr($class, $pos + Proxy::MARKER_LENGTH + 2);
}
private static function getMappingValue(array|JoinColumnMapping $mapping, string $key): mixed
{
if ($mapping instanceof JoinColumnMapping) {
return $mapping->$key ?? null;
}
return $mapping[$key] ?? null;
}
}

View File

@@ -0,0 +1,52 @@
<?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\Bridge\Doctrine\Form\EventListener;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
/**
* Merge changes from the request to a Doctrine\Common\Collections\Collection instance.
*
* This works with ORM, MongoDB and CouchDB instances of the collection interface.
*
* @author Bernhard Schussek <bschussek@gmail.com>
*
* @see Collection
*/
class MergeDoctrineCollectionListener implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
// Higher priority than core MergeCollectionListener so that this one
// is called before
return [
FormEvents::SUBMIT => [
['onSubmit', 5],
],
];
}
public function onSubmit(FormEvent $event): void
{
$collection = $event->getForm()->getData();
$data = $event->getData();
// If all items were removed, call clear which has a higher
// performance on persistent collections
if ($collection instanceof Collection && 0 === \count($data)) {
$collection->clear();
}
}
}

View File

@@ -0,0 +1,256 @@
<?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\Bridge\Doctrine\Form\Type;
use Doctrine\Common\Collections\Collection;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager;
use Symfony\Bridge\Doctrine\Form\ChoiceList\DoctrineChoiceLoader;
use Symfony\Bridge\Doctrine\Form\ChoiceList\EntityLoaderInterface;
use Symfony\Bridge\Doctrine\Form\ChoiceList\IdReader;
use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer;
use Symfony\Bridge\Doctrine\Form\EventListener\MergeDoctrineCollectionListener;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\ChoiceList\ChoiceList;
use Symfony\Component\Form\ChoiceList\Factory\CachingFactoryDecorator;
use Symfony\Component\Form\Exception\RuntimeException;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Service\ResetInterface;
abstract class DoctrineType extends AbstractType implements ResetInterface
{
/**
* @var IdReader[]
*/
private array $idReaders = [];
/**
* @var EntityLoaderInterface[]
*/
private array $entityLoaders = [];
/**
* Creates the label for a choice.
*
* For backwards compatibility, objects are cast to strings by default.
*
* @internal This method is public to be usable as callback. It should not
* be used in user code.
*/
public static function createChoiceLabel(object $choice): string
{
return (string) $choice;
}
/**
* Creates the field name for a choice.
*
* This method is used to generate field names if the underlying object has
* a single-column integer ID. In that case, the value of the field is
* the ID of the object. That ID is also used as field name.
*
* @param string $value The choice value. Corresponds to the object's ID here.
*
* @internal This method is public to be usable as callback. It should not
* be used in user code.
*/
public static function createChoiceName(object $choice, int|string $key, string $value): string
{
return str_replace('-', '_', $value);
}
/**
* Gets important parts from QueryBuilder that will allow to cache its results.
* For instance in ORM two query builders with an equal SQL string and
* equal parameters are considered to be equal.
*
* @param object $queryBuilder A query builder, type declaration is not present here as there
* is no common base class for the different implementations
*
* @internal This method is public to be usable as callback. It should not
* be used in user code.
*/
public function getQueryBuilderPartsForCachingHash(object $queryBuilder): ?array
{
return null;
}
public function __construct(
protected ManagerRegistry $registry,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
if ($options['multiple'] && interface_exists(Collection::class)) {
$builder
->addEventSubscriber(new MergeDoctrineCollectionListener())
->addViewTransformer(new CollectionToArrayTransformer(), true)
;
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$choiceLoader = function (Options $options) {
// Unless the choices are given explicitly, load them on demand
if (null === $options['choices']) {
// If there is no QueryBuilder we can safely cache
$vary = [$options['em'], $options['class']];
// also if concrete Type can return important QueryBuilder parts to generate
// hash key we go for it as well, otherwise fallback on the instance
if ($options['query_builder']) {
$vary[] = $this->getQueryBuilderPartsForCachingHash($options['query_builder']) ?? $options['query_builder'];
}
return ChoiceList::loader($this, new DoctrineChoiceLoader(
$options['em'],
$options['class'],
$options['id_reader'],
$this->getCachedEntityLoader(
$options['em'],
$options['query_builder'] ?? $options['em']->getRepository($options['class'])->createQueryBuilder('e'),
$options['class'],
$vary
)
), $vary);
}
return null;
};
$choiceName = function (Options $options) {
// If the object has a single-column, numeric ID, use that ID as
// field name. We can only use numeric IDs as names, as we cannot
// guarantee that a non-numeric ID contains a valid form name
if ($options['id_reader'] instanceof IdReader && $options['id_reader']->isIntId()) {
return ChoiceList::fieldName($this, [__CLASS__, 'createChoiceName']);
}
// Otherwise, an incrementing integer is used as name automatically
return null;
};
// The choices are always indexed by ID (see "choices" normalizer
// and DoctrineChoiceLoader), unless the ID is composite. Then they
// are indexed by an incrementing integer.
// Use the ID/incrementing integer as choice value.
$choiceValue = function (Options $options) {
// If the entity has a single-column ID, use that ID as value
if ($options['id_reader'] instanceof IdReader && $options['id_reader']->isSingleId()) {
return ChoiceList::value($this, $options['id_reader']->getIdValue(...), $options['id_reader']);
}
// Otherwise, an incrementing integer is used as value automatically
return null;
};
$emNormalizer = function (Options $options, $em) {
if (null !== $em) {
if ($em instanceof ObjectManager) {
return $em;
}
return $this->registry->getManager($em);
}
$em = $this->registry->getManagerForClass($options['class']);
if (null === $em) {
throw new RuntimeException(\sprintf('Class "%s" seems not to be a managed Doctrine entity. Did you forget to map it?', $options['class']));
}
return $em;
};
// Invoke the query builder closure so that we can cache choice lists
// for equal query builders
$queryBuilderNormalizer = function (Options $options, $queryBuilder) {
if (\is_callable($queryBuilder)) {
$queryBuilder = $queryBuilder($options['em']->getRepository($options['class']));
}
return $queryBuilder;
};
// Set the "id_reader" option via the normalizer. This option is not
// supposed to be set by the user.
// The ID reader is a utility that is needed to read the object IDs
// when generating the field values. The callback generating the
// field values has no access to the object manager or the class
// of the field, so we store that information in the reader.
// The reader is cached so that two choice lists for the same class
// (and hence with the same reader) can successfully be cached.
$idReaderNormalizer = fn (Options $options) => $this->getCachedIdReader($options['em'], $options['class']);
$resolver->setDefaults([
'em' => null,
'query_builder' => null,
'choices' => null,
'choice_loader' => $choiceLoader,
'choice_label' => ChoiceList::label($this, [__CLASS__, 'createChoiceLabel']),
'choice_name' => $choiceName,
'choice_value' => $choiceValue,
'id_reader' => null, // internal
'choice_translation_domain' => false,
]);
$resolver->setRequired(['class']);
$resolver->setNormalizer('em', $emNormalizer);
$resolver->setNormalizer('query_builder', $queryBuilderNormalizer);
$resolver->setNormalizer('id_reader', $idReaderNormalizer);
$resolver->setAllowedTypes('em', ['null', 'string', ObjectManager::class]);
}
/**
* Return the default loader object.
*/
abstract public function getLoader(ObjectManager $manager, object $queryBuilder, string $class): EntityLoaderInterface;
public function getParent(): string
{
return ChoiceType::class;
}
public function reset(): void
{
$this->idReaders = [];
$this->entityLoaders = [];
}
private function getCachedIdReader(ObjectManager $manager, string $class): ?IdReader
{
$hash = CachingFactoryDecorator::generateHash([$manager, $class]);
if (isset($this->idReaders[$hash])) {
return $this->idReaders[$hash];
}
$idReader = new IdReader($manager, $manager->getClassMetadata($class));
// don't cache the instance for composite ids that cannot be optimized
return $this->idReaders[$hash] = $idReader->isSingleId() ? $idReader : null;
}
private function getCachedEntityLoader(ObjectManager $manager, object $queryBuilder, string $class, array $vary): EntityLoaderInterface
{
$hash = CachingFactoryDecorator::generateHash($vary);
return $this->entityLoaders[$hash] ??= $this->getLoader($manager, $queryBuilder, $class);
}
}

View File

@@ -0,0 +1,93 @@
<?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\Bridge\Doctrine\Form\Type;
use Doctrine\ORM\Query\Parameter;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectManager;
use Symfony\Bridge\Doctrine\Form\ChoiceList\ORMQueryBuilderLoader;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class EntityType extends DoctrineType
{
public function configureOptions(OptionsResolver $resolver): void
{
parent::configureOptions($resolver);
// Invoke the query builder closure so that we can cache choice lists
// for equal query builders
$queryBuilderNormalizer = function (Options $options, $queryBuilder) {
if (\is_callable($queryBuilder)) {
$queryBuilder = $queryBuilder($options['em']->getRepository($options['class']));
if (null !== $queryBuilder && !$queryBuilder instanceof QueryBuilder) {
throw new UnexpectedTypeException($queryBuilder, QueryBuilder::class);
}
}
return $queryBuilder;
};
$resolver->setNormalizer('query_builder', $queryBuilderNormalizer);
$resolver->setAllowedTypes('query_builder', ['null', 'callable', QueryBuilder::class]);
}
/**
* Return the default loader object.
*
* @param QueryBuilder $queryBuilder
*/
public function getLoader(ObjectManager $manager, object $queryBuilder, string $class): ORMQueryBuilderLoader
{
if (!$queryBuilder instanceof QueryBuilder) {
throw new \TypeError(\sprintf('Expected an instance of "%s", but got "%s".', QueryBuilder::class, get_debug_type($queryBuilder)));
}
return new ORMQueryBuilderLoader($queryBuilder);
}
public function getBlockPrefix(): string
{
return 'entity';
}
/**
* We consider two query builders with an equal SQL string and
* equal parameters to be equal.
*
* @param QueryBuilder $queryBuilder
*
* @internal This method is public to be usable as callback. It should not
* be used in user code.
*/
public function getQueryBuilderPartsForCachingHash(object $queryBuilder): ?array
{
if (!$queryBuilder instanceof QueryBuilder) {
throw new \TypeError(\sprintf('Expected an instance of "%s", but got "%s".', QueryBuilder::class, get_debug_type($queryBuilder)));
}
return [
$queryBuilder->getQuery()->getSQL(),
array_map($this->parameterToArray(...), $queryBuilder->getParameters()->toArray()),
];
}
/**
* Converts a query parameter to an array.
*/
private function parameterToArray(Parameter $parameter): array
{
return [$parameter->getName(), $parameter->getType(), $parameter->getValue()];
}
}