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,245 @@
<?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\ArgumentResolver;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NoResultException;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Yields the entity matching the criteria provided in the route.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Jérémy Derussé <jeremy@derusse.com>
*/
final class EntityValueResolver implements ValueResolverInterface
{
public function __construct(
private ManagerRegistry $registry,
private ?ExpressionLanguage $expressionLanguage = null,
private MapEntity $defaults = new MapEntity(),
/** @var array<class-string, class-string> */
private readonly array $typeAliases = [],
) {
}
public function resolve(Request $request, ArgumentMetadata $argument): array
{
if (\is_object($request->attributes->get($argument->getName()))) {
return [];
}
$options = $argument->getAttributes(MapEntity::class, ArgumentMetadata::IS_INSTANCEOF);
$options = ($options[0] ?? $this->defaults)->withDefaults($this->defaults, $argument->getType());
if (!$options->class || $options->disabled) {
return [];
}
$options->class = $this->typeAliases[$options->class] ?? $options->class;
if (!$manager = $this->getManager($options->objectManager, $options->class)) {
return [];
}
$message = '';
if (null !== $options->expr) {
if (null === $object = $this->findViaExpression($manager, $request, $options)) {
$message = \sprintf(' The expression "%s" returned null.', $options->expr);
}
// find by identifier?
} elseif (false === $object = $this->find($manager, $request, $options, $argument)) {
// find by criteria
if (!$criteria = $this->getCriteria($request, $options, $manager, $argument)) {
if (!class_exists(NearMissValueResolverException::class)) {
return [];
}
throw new NearMissValueResolverException(\sprintf('Cannot find mapping for "%s": declare one using either the #[MapEntity] attribute or mapped route parameters.', $options->class));
}
try {
$object = $manager->getRepository($options->class)->findOneBy($criteria);
} catch (NoResultException|ConversionException) {
$object = null;
}
}
if (null === $object && !$argument->isNullable()) {
throw new NotFoundHttpException($options->message ?? (\sprintf('"%s" object not found by "%s".', $options->class, self::class).$message));
}
return [$object];
}
private function getManager(?string $name, string $class): ?ObjectManager
{
if (null === $name) {
return $this->registry->getManagerForClass($class);
}
try {
$manager = $this->registry->getManager($name);
} catch (\InvalidArgumentException) {
return null;
}
return $manager->getMetadataFactory()->isTransient($class) ? null : $manager;
}
private function find(ObjectManager $manager, Request $request, MapEntity $options, ArgumentMetadata $argument): false|object|null
{
if ($options->mapping || $options->exclude) {
return false;
}
$id = $this->getIdentifier($request, $options, $argument);
if (false === $id || null === $id) {
return $id;
}
if (\is_array($id) && \in_array(null, $id, true)) {
return null;
}
if ($options->evictCache && $manager instanceof EntityManagerInterface) {
$cacheProvider = $manager->getCache();
if ($cacheProvider && $cacheProvider->containsEntity($options->class, $id)) {
$cacheProvider->evictEntity($options->class, $id);
}
}
try {
return $manager->getRepository($options->class)->find($id);
} catch (NoResultException|ConversionException) {
return null;
}
}
private function getIdentifier(Request $request, MapEntity $options, ArgumentMetadata $argument): mixed
{
if (\is_array($options->id)) {
$id = [];
foreach ($options->id as $field) {
// Convert "%s_uuid" to "foobar_uuid"
if (str_contains($field, '%s')) {
$field = \sprintf($field, $argument->getName());
}
$id[$field] = $request->attributes->get($field);
}
return $id;
}
if ($options->id) {
return $request->attributes->get($options->id) ?? ($options->stripNull ? false : null);
}
$name = $argument->getName();
if ($request->attributes->has($name)) {
if (\is_array($id = $request->attributes->get($name))) {
return false;
}
foreach ($request->attributes->get('_route_mapping') ?? [] as $parameter => $attribute) {
if ($name === $attribute) {
$options->mapping = [$name => $parameter];
return false;
}
}
return $id ?? ($options->stripNull ? false : null);
}
if ($request->attributes->has('id')) {
return $request->attributes->get('id') ?? ($options->stripNull ? false : null);
}
return false;
}
private function getCriteria(Request $request, MapEntity $options, ObjectManager $manager, ArgumentMetadata $argument): array
{
if (!($mapping = $options->mapping) && \is_array($criteria = $request->attributes->get($argument->getName()))) {
foreach ($options->exclude as $exclude) {
unset($criteria[$exclude]);
}
if ($options->stripNull) {
$criteria = array_filter($criteria, static fn ($value) => null !== $value);
}
return $criteria;
} elseif (null === $mapping) {
trigger_deprecation('symfony/doctrine-bridge', '7.1', 'Relying on auto-mapping for Doctrine entities is deprecated for argument $%s of "%s": declare the mapping using either the #[MapEntity] attribute or mapped route parameters.', $argument->getName(), method_exists($argument, 'getControllerName') ? $argument->getControllerName() : 'n/a');
$mapping = $request->attributes->keys();
}
if ($mapping && array_is_list($mapping)) {
$mapping = array_combine($mapping, $mapping);
}
foreach ($options->exclude as $exclude) {
unset($mapping[$exclude]);
}
if (!$mapping) {
return [];
}
$criteria = [];
$metadata = null === $options->mapping ? $manager->getClassMetadata($options->class) : false;
foreach ($mapping as $attribute => $field) {
if ($metadata && !$metadata->hasField($field) && (!$metadata->hasAssociation($field) || !$metadata->isSingleValuedAssociation($field))) {
continue;
}
$criteria[$field] = $request->attributes->get($attribute);
}
if ($options->stripNull) {
$criteria = array_filter($criteria, static fn ($value) => null !== $value);
}
return $criteria;
}
private function findViaExpression(ObjectManager $manager, Request $request, MapEntity $options): object|iterable|null
{
if (!$this->expressionLanguage) {
throw new \LogicException(\sprintf('You cannot use the "%s" if the ExpressionLanguage component is not available. Try running "composer require symfony/expression-language".', __CLASS__));
}
$repository = $manager->getRepository($options->class);
$variables = array_merge($request->attributes->all(), [
'repository' => $repository,
'request' => $request,
]);
try {
return $this->expressionLanguage->evaluate($options->expr, $variables);
} catch (NoResultException|ConversionException) {
return null;
}
}
}

View File

@@ -0,0 +1,84 @@
<?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\Attribute;
use Symfony\Bridge\Doctrine\ArgumentResolver\EntityValueResolver;
use Symfony\Component\HttpKernel\Attribute\ValueResolver;
/**
* Indicates that a controller argument should receive an Entity.
*/
#[\Attribute(\Attribute::TARGET_PARAMETER)]
class MapEntity extends ValueResolver
{
/**
* @param class-string|null $class The entity class
* @param string|null $objectManager Specify the object manager used to retrieve the entity
* @param string|null $expr An expression to fetch the entity using the {@see https://symfony.com/doc/current/components/expression_language.html ExpressionLanguage} syntax.
* Any request attribute are available as a variable, and your entity repository in the 'repository' variable.
* @param array<string, string>|null $mapping Configures the properties and values to use with the findOneBy() method
* The key is the route placeholder name and the value is the Doctrine property name
* @param string[]|null $exclude Configures the properties that should be used in the findOneBy() method by excluding
* one or more properties so that not all are used
* @param bool|null $stripNull Whether to prevent null values from being used as parameters in the query (defaults to false)
* @param string[]|string|null $id If an id option is configured and matches a route parameter, then the resolver will find by the primary key
* @param bool|null $evictCache If true, forces Doctrine to always fetch the entity from the database instead of cache (defaults to false)
*/
public function __construct(
public ?string $class = null,
public ?string $objectManager = null,
public ?string $expr = null,
public ?array $mapping = null,
public ?array $exclude = null,
public ?bool $stripNull = null,
public array|string|null $id = null,
public ?bool $evictCache = null,
bool $disabled = false,
string $resolver = EntityValueResolver::class,
public ?string $message = null,
) {
parent::__construct($resolver, $disabled);
$this->selfValidate();
}
public function withDefaults(self $defaults, ?string $class): static
{
$clone = clone $this;
$clone->class ??= class_exists($class ?? '') || interface_exists($class ?? '', false) ? $class : null;
$clone->objectManager ??= $defaults->objectManager;
$clone->expr ??= $defaults->expr;
$clone->mapping ??= $defaults->mapping;
$clone->exclude ??= $defaults->exclude ?? [];
$clone->stripNull ??= $defaults->stripNull ?? false;
$clone->id ??= $defaults->id;
$clone->evictCache ??= $defaults->evictCache ?? false;
$clone->message ??= $defaults->message;
$clone->selfValidate();
return $clone;
}
private function selfValidate(): void
{
if (!$this->id) {
return;
}
if ($this->mapping) {
throw new \LogicException('The "id" and "mapping" options cannot be used together on #[MapEntity] attributes.');
}
if ($this->exclude) {
throw new \LogicException('The "id" and "exclude" options cannot be used together on #[MapEntity] attributes.');
}
$this->mapping = [];
}
}

View File

@@ -0,0 +1,215 @@
CHANGELOG
=========
7.4
---
* Deprecate `UniqueEntity::getRequiredOptions()` and `UniqueEntity::getDefaultOption()`
* Use a single table named `schema_subscriber_check_` in schema listeners to detect same database connections
* Add support for `Symfony\Component\Clock\DatePoint` as `DayPointType` and `TimePointType` Doctrine type
* Deprecate the `AbstractDoctrineExtension` class; its code is incorporated into the extension classes of Doctrine bundles
7.3
---
* Reset the manager registry using native lazy objects when applicable
* Deprecate the `DoctrineExtractor::getTypes()` method, use `DoctrineExtractor::getType()` instead
* Add support for `Symfony\Component\Clock\DatePoint` as `DatePointType` Doctrine type
* Improve exception message when `EntityValueResolver` gets no mapping information
* Add type aliases support to `EntityValueResolver`
7.2
---
* Accept `ReadableCollection` in `CollectionToArrayTransformer`
7.1
---
* Allow `EntityValueResolver` to return a list of entities
* Add support for auto-closing idle connections
* Allow validating every class against `UniqueEntity` constraint
* Deprecate auto-mapping of entities in favor of mapped route parameters
7.0
---
* Remove `DoctrineDbalCacheAdapterSchemaSubscriber`, use `DoctrineDbalCacheAdapterSchemaListener` instead
* Remove `MessengerTransportDoctrineSchemaSubscriber`, use `MessengerTransportDoctrineSchemaListener` instead
* Remove `RememberMeTokenProviderDoctrineSchemaSubscriber`, use `RememberMeTokenProviderDoctrineSchemaListener` instead
* Remove `DbalLogger`, use a middleware instead
* Remove `DoctrineDataCollector::addLogger()`, use a `DebugDataHolder` instead
* Remove `ContainerAwareLoader`, use dependency injection in your fixtures instead
* `ContainerAwareEventManager::getListeners()` must be called with an event name
* DoctrineBridge now requires `doctrine/event-manager:^2`
* Add parameter `$isSameDatabase` to `DoctrineTokenProvider::configureSchema()`
6.4
---
* [BC BREAK] Add argument `$buildDir` to `ProxyCacheWarmer::warmUp()`
* [BC BREAK] Add return type-hints to `EntityFactory`
* Deprecate `DbalLogger`, use a middleware instead
* Deprecate not constructing `DoctrineDataCollector` with an instance of `DebugDataHolder`
* Deprecate `DoctrineDataCollector::addLogger()`, use a `DebugDataHolder` instead
* Deprecate `ContainerAwareLoader`, use dependency injection in your fixtures instead
* Always pass the `Request` object to `EntityValueResolver`'s expression
* [BC BREAK] Change argument `$lastUsed` of `DoctrineTokenProvider::updateToken()` to accept `DateTimeInterface`
6.3
---
* Deprecate passing Doctrine subscribers to `ContainerAwareEventManager` class, use listeners instead
* Add `AbstractSchemaListener`, `LockStoreSchemaListener` and `PdoSessionHandlerSchemaListener`
* Deprecate `DoctrineDbalCacheAdapterSchemaSubscriber` in favor of `DoctrineDbalCacheAdapterSchemaListener`
* Deprecate `MessengerTransportDoctrineSchemaSubscriber` in favor of `MessengerTransportDoctrineSchemaListener`
* Deprecate `RememberMeTokenProviderDoctrineSchemaSubscriber` in favor of `RememberMeTokenProviderDoctrineSchemaListener`
* Add optional parameter `$isSameDatabase` to `DoctrineTokenProvider::configureSchema()`
6.2
---
* Add `#[MapEntity]` with its corresponding `EntityValueResolver`
* Add `NAME` constant to `UlidType` and `UuidType`
6.0
---
* Remove `DoctrineTestHelper` and `TestRepositoryFactory`
5.4
---
* Add `DoctrineOpenTransactionLoggerMiddleware` to log when a transaction has been left open
* Deprecate `PdoCacheAdapterDoctrineSchemaSubscriber` and add `DoctrineDbalCacheAdapterSchemaSubscriber` instead
* `UniqueEntity` constraint retrieves a maximum of two entities if the default repository method is used.
* Add support for the newer bundle structure to `AbstractDoctrineExtension::loadMappingInformation()`
* Add argument `$bundleDir` to `AbstractDoctrineExtension::getMappingDriverBundleConfigDefaults()`
* Add argument `$bundleDir` to `AbstractDoctrineExtension::getMappingResourceConfigDirectory()`
5.3
---
* Deprecate `UserLoaderInterface::loadUserByUsername()` in favor of `UserLoaderInterface::loadUserByIdentifier()
* Deprecate `DoctrineTestHelper` and `TestRepositoryFactory`
* [BC BREAK] Remove `UuidV*Generator` classes
* Add `UuidGenerator`
* Add support for the new security-core `TokenVerifierInterface` in `DoctrineTokenProvider`, fixing parallel requests handling in remember-me
5.2.0
-----
* added support for symfony/uid as `UlidType` and `UuidType` as Doctrine types
* added `UlidGenerator`, `UuidV1Generator`, `UuidV4Generator` and `UuidV6Generator`
5.0.0
-----
* the `getMetadataDriverClass()` method is abstract and must be implemented by class extending `AbstractDoctrineExtension`
* passing an `IdReader` to the `DoctrineChoiceLoader` when the query cannot be optimized with single id field, throws an exception; pass `null` instead
* not explicitly passing an instance of `IdReader` to `DoctrineChoiceLoader` when it can optimize single id field, will not apply any optimization
* `DoctrineExtractor` now requires an `EntityManagerInterface` on instantiation
4.4.0
-----
* [BC BREAK] using null as `$classValidatorRegexp` value in `DoctrineLoader::__construct` will not enable auto-mapping for all classes anymore, use `'{.*}'` instead.
* added `DoctrineClearEntityManagerWorkerSubscriber`
* deprecated `RegistryInterface`, use `Doctrine\Persistence\ManagerRegistry`
* added support for invokable event listeners
* added `getMetadataDriverClass` method to deprecate class parameters in service configuration files
4.3.0
-----
* changed guessing of DECIMAL to set the `input` option of `NumberType` to string
* deprecated not passing an `IdReader` to the `DoctrineChoiceLoader` when query can be optimized with a single id field
* deprecated passing an `IdReader` to the `DoctrineChoiceLoader` when entities have a composite id
* added two Messenger middleware: `DoctrinePingConnectionMiddleware` and `DoctrineCloseConnectionMiddleware`
4.2.0
-----
* deprecated injecting `ClassMetadataFactory` in `DoctrineExtractor`,
an instance of `EntityManagerInterface` should be injected instead
* added support for `simple_array` type
* the `DoctrineTransactionMiddlewareFactory` class has been removed
4.1.0
-----
* added support for datetime immutable types in form type guesser
4.0.0
-----
* the first constructor argument of the `DoctrineChoiceLoader` class must be
an `ObjectManager` implementation
* removed the `MergeDoctrineCollectionListener::onBind()` method
* trying to reset a non-lazy manager service using the `ManagerRegistry::resetService()`
method throws an exception
* removed the `DoctrineParserCache` class
3.4.0
-----
* added support for doctrine/dbal v2.6 types
* added cause of UniqueEntity constraint violation
* deprecated `DbalSessionHandler` and `DbalSessionHandlerSchema` in favor of
`Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler`
3.1.0
-----
* added "{{ value }}" message placeholder to UniqueEntityValidator
* deprecated `MergeDoctrineCollectionListener::onBind` in favor of
`MergeDoctrineCollectionListener::onSubmit`
* deprecated passing `ChoiceListFactoryInterface` as first argument of
`DoctrineChoiceLoader`'s constructor
3.0.0
-----
* removed `EntityChoiceList`
* removed `$manager` (2nd) and `$class` (3th) arguments of `ORMQueryBuilderLoader`
* removed passing a query builder closure to `ORMQueryBuilderLoader`
* removed `loader` and `property` options of the `DoctrineType`
2.8.0
-----
* deprecated using the entity provider with a Doctrine repository implementing UserProviderInterface
* added UserLoaderInterface for loading users through Doctrine.
2.7.0
-----
* added DoctrineChoiceLoader
* deprecated EntityChoiceList
* deprecated passing a query builder closure to ORMQueryBuilderLoader
* deprecated $manager and $em arguments of ORMQueryBuilderLoader
* added optional arguments $propertyAccessor and $choiceListFactory to DoctrineOrmExtension constructor
* deprecated "loader" and "property" options of DoctrineType
2.4.0
-----
* deprecated DoctrineOrmTestCase class
2.2.0
-----
* added an optional PropertyAccessorInterface parameter to DoctrineType,
EntityType and EntityChoiceList
2.1.0
-----
* added a default implementation of the ManagerRegistry
* added a session storage for Doctrine DBAL
* DoctrineOrmTypeGuesser now guesses "collection" for array Doctrine type
* DoctrineType now caches its choice lists in order to improve performance
* DoctrineType now uses ManagerRegistry::getManagerForClass() if the option "em" is not set
* UniqueEntity validation constraint now accepts a "repositoryMethod" option that will be used to check for uniqueness instead of the default "findBy"
* [BC BREAK] the DbalLogger::log() visibility has been changed from public to
protected

View File

@@ -0,0 +1,73 @@
<?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\CacheWarmer;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
/**
* The proxy generator cache warmer generates all entity proxies.
*
* In the process of generating proxies the cache for all the metadata is primed also,
* since this information is necessary to build the proxies in the first place.
*
* @author Benjamin Eberlei <kontakt@beberlei.de>
*
* @final since Symfony 7.1
*/
class ProxyCacheWarmer implements CacheWarmerInterface
{
public function __construct(
private readonly ManagerRegistry $registry,
) {
}
/**
* This cache warmer is not optional, without proxies fatal error occurs!
*/
public function isOptional(): bool
{
return false;
}
public function warmUp(string $cacheDir, ?string $buildDir = null): array
{
$files = [];
foreach ($this->registry->getManagers() as $em) {
// we need the directory no matter the proxy cache generation strategy
if (!is_dir($proxyCacheDir = $em->getConfiguration()->getProxyDir())) {
if (false === @mkdir($proxyCacheDir, 0o777, true) && !is_dir($proxyCacheDir)) {
throw new \RuntimeException(\sprintf('Unable to create the Doctrine Proxy directory "%s".', $proxyCacheDir));
}
} elseif (!is_writable($proxyCacheDir)) {
throw new \RuntimeException(\sprintf('The Doctrine Proxy directory "%s" is not writeable for the current system user.', $proxyCacheDir));
}
// if proxies are autogenerated we don't need to generate them in the cache warmer
if ($em->getConfiguration()->getAutoGenerateProxyClasses()) {
continue;
}
$classes = $em->getMetadataFactory()->getAllMetadata();
$em->getProxyFactory()->generateProxyClasses($classes);
foreach (scandir($proxyCacheDir) as $file) {
if (!is_dir($file = $proxyCacheDir.'/'.$file)) {
$files[] = $file;
}
}
}
return $files;
}
}

View File

@@ -0,0 +1,217 @@
<?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;
use Doctrine\Common\EventArgs;
use Doctrine\Common\EventManager;
use Doctrine\Common\EventSubscriber;
use Psr\Container\ContainerInterface;
/**
* Allows lazy loading of listener and subscriber services.
*
* @author Johannes M. Schmitt <schmittjoh@gmail.com>
*/
class ContainerAwareEventManager extends EventManager
{
private array $initialized = [];
private bool $initializedSubscribers = false;
private array $initializedHashMapping = [];
private array $methods = [];
/**
* @param list<array{string[], string|object}> $listeners List of [events, listener] tuples
*/
public function __construct(
private ContainerInterface $container,
private array $listeners = [],
) {
}
public function dispatchEvent(string $eventName, ?EventArgs $eventArgs = null): void
{
if (!$this->initializedSubscribers) {
$this->initializeSubscribers();
}
if (!isset($this->listeners[$eventName])) {
return;
}
$eventArgs ??= EventArgs::getEmptyInstance();
if (!isset($this->initialized[$eventName])) {
$this->initializeListeners($eventName);
}
foreach ($this->listeners[$eventName] as $hash => $listener) {
$listener->{$this->methods[$eventName][$hash]}($eventArgs);
}
}
public function getListeners(string $event): array
{
if (!$this->initializedSubscribers) {
$this->initializeSubscribers();
}
if (!isset($this->initialized[$event])) {
$this->initializeListeners($event);
}
return $this->listeners[$event];
}
public function getAllListeners(): array
{
if (!$this->initializedSubscribers) {
$this->initializeSubscribers();
}
foreach ($this->listeners as $event => $listeners) {
if (!isset($this->initialized[$event])) {
$this->initializeListeners($event);
}
}
return $this->listeners;
}
public function hasListeners(string $event): bool
{
if (!$this->initializedSubscribers) {
$this->initializeSubscribers();
}
return isset($this->listeners[$event]) && $this->listeners[$event];
}
public function addEventListener(string|array $events, object|string $listener): void
{
if (!$this->initializedSubscribers) {
$this->initializeSubscribers();
}
$hash = $this->getHash($listener);
foreach ((array) $events as $event) {
// Overrides listener if a previous one was associated already
// Prevents duplicate listeners on same event (same instance only)
$this->listeners[$event][$hash] = $listener;
if (\is_string($listener)) {
unset($this->initialized[$event]);
unset($this->initializedHashMapping[$event][$hash]);
} else {
$this->methods[$event][$hash] = $this->getMethod($listener, $event);
}
}
}
public function removeEventListener(string|array $events, object|string $listener): void
{
if (!$this->initializedSubscribers) {
$this->initializeSubscribers();
}
$hash = $this->getHash($listener);
foreach ((array) $events as $event) {
if (isset($this->initializedHashMapping[$event][$hash])) {
$hash = $this->initializedHashMapping[$event][$hash];
unset($this->initializedHashMapping[$event][$hash]);
}
// Check if we actually have this listener associated
if (isset($this->listeners[$event][$hash])) {
unset($this->listeners[$event][$hash]);
}
if (isset($this->methods[$event][$hash])) {
unset($this->methods[$event][$hash]);
}
}
}
public function addEventSubscriber(EventSubscriber $subscriber): void
{
if (!$this->initializedSubscribers) {
$this->initializeSubscribers();
}
parent::addEventSubscriber($subscriber);
}
public function removeEventSubscriber(EventSubscriber $subscriber): void
{
if (!$this->initializedSubscribers) {
$this->initializeSubscribers();
}
parent::removeEventSubscriber($subscriber);
}
private function initializeListeners(string $eventName): void
{
$this->initialized[$eventName] = true;
// We'll refill the whole array in order to keep the same order
$listeners = [];
foreach ($this->listeners[$eventName] as $hash => $listener) {
if (\is_string($listener)) {
$listener = $this->container->get($listener);
$newHash = $this->getHash($listener);
$this->initializedHashMapping[$eventName][$hash] = $newHash;
$listeners[$newHash] = $listener;
$this->methods[$eventName][$newHash] = $this->getMethod($listener, $eventName);
} else {
$listeners[$hash] = $listener;
}
}
$this->listeners[$eventName] = $listeners;
}
private function initializeSubscribers(): void
{
$this->initializedSubscribers = true;
$listeners = $this->listeners;
$this->listeners = [];
foreach ($listeners as $listener) {
if (\is_array($listener)) {
$this->addEventListener(...$listener);
continue;
}
throw new \InvalidArgumentException(\sprintf('Using Doctrine subscriber "%s" is not allowed. Register it as a listener instead, using e.g. the #[AsDoctrineListener] or #[AsDocumentListener] attribute.', \is_object($listener) ? get_debug_type($listener) : $listener));
}
}
private function getHash(string|object $listener): string
{
if (\is_string($listener)) {
return '_service_'.$listener;
}
return spl_object_hash($listener);
}
private function getMethod(object $listener, string $event): string
{
if (!method_exists($listener, $event) && method_exists($listener, '__invoke')) {
return '__invoke';
}
return $event;
}
}

View File

@@ -0,0 +1,222 @@
<?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\DataCollector;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bridge\Doctrine\Middleware\Debug\DebugDataHolder;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
use Symfony\Component\VarDumper\Caster\Caster;
use Symfony\Component\VarDumper\Cloner\Stub;
/**
* DoctrineDataCollector.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class DoctrineDataCollector extends DataCollector
{
private array $connections;
private array $managers;
public function __construct(
private ManagerRegistry $registry,
private DebugDataHolder $debugDataHolder,
) {
$this->connections = $registry->getConnectionNames();
$this->managers = $registry->getManagerNames();
}
public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
{
$this->data = [
'queries' => $this->collectQueries(),
'connections' => $this->connections,
'managers' => $this->managers,
];
}
private function collectQueries(): array
{
$queries = [];
foreach ($this->debugDataHolder->getData() as $name => $data) {
$queries[$name] = $this->sanitizeQueries($name, $data);
}
return $queries;
}
public function reset(): void
{
$this->data = [];
$this->debugDataHolder->reset();
}
public function getManagers(): array
{
return $this->data['managers'];
}
public function getConnections(): array
{
return $this->data['connections'];
}
public function getQueryCount(): int
{
return array_sum(array_map('count', $this->data['queries']));
}
public function getQueries(): array
{
return $this->data['queries'];
}
public function getTime(): float
{
$time = 0;
foreach ($this->data['queries'] as $queries) {
foreach ($queries as $query) {
$time += $query['executionMS'];
}
}
return $time;
}
public function getName(): string
{
return 'db';
}
protected function getCasters(): array
{
return parent::getCasters() + [
ObjectParameter::class => static function (ObjectParameter $o, array $a, Stub $s): array {
$s->class = $o->getClass();
$s->value = $o->getObject();
$r = new \ReflectionClass($o->getClass());
if ($f = $r->getFileName()) {
$s->attr['file'] = $f;
$s->attr['line'] = $r->getStartLine();
} else {
unset($s->attr['file']);
unset($s->attr['line']);
}
if ($error = $o->getError()) {
return [Caster::PREFIX_VIRTUAL.'⚠' => $error->getMessage()];
}
if ($o->isStringable()) {
return [Caster::PREFIX_VIRTUAL.'__toString()' => (string) $o->getObject()];
}
return [Caster::PREFIX_VIRTUAL.'⚠' => \sprintf('Object of class "%s" could not be converted to string.', $o->getClass())];
},
];
}
private function sanitizeQueries(string $connectionName, array $queries): array
{
foreach ($queries as $i => $query) {
$queries[$i] = $this->sanitizeQuery($connectionName, $query);
}
return $queries;
}
private function sanitizeQuery(string $connectionName, array $query): array
{
$query['explainable'] = true;
$query['runnable'] = true;
$query['params'] ??= [];
if (!\is_array($query['params'])) {
$query['params'] = [$query['params']];
}
if (!\is_array($query['types'])) {
$query['types'] = [];
}
foreach ($query['params'] as $j => $param) {
$e = null;
if (isset($query['types'][$j])) {
// Transform the param according to the type
$type = $query['types'][$j];
if (\is_string($type)) {
$type = Type::getType($type);
}
if ($type instanceof Type) {
$query['types'][$j] = $type->getBindingType();
try {
$param = $type->convertToDatabaseValue($param, $this->registry->getConnection($connectionName)->getDatabasePlatform());
} catch (\TypeError|ConversionException) {
}
}
}
[$query['params'][$j], $explainable, $runnable] = $this->sanitizeParam($param, $e);
if (!$explainable) {
$query['explainable'] = false;
}
if (!$runnable) {
$query['runnable'] = false;
}
}
$query['params'] = $this->cloneVar($query['params']);
return $query;
}
/**
* Sanitizes a param.
*
* The return value is an array with the sanitized value and a boolean
* indicating if the original value was kept (allowing to use the sanitized
* value to explain the query).
*/
private function sanitizeParam(mixed $var, ?\Throwable $error): array
{
if (\is_object($var)) {
return [$o = new ObjectParameter($var, $error), false, $o->isStringable() && !$error];
}
if ($error) {
return ['⚠ '.$error->getMessage(), false, false];
}
if (\is_array($var)) {
$a = [];
$explainable = $runnable = true;
foreach ($var as $k => $v) {
[$value, $e, $r] = $this->sanitizeParam($v, null);
$explainable = $explainable && $e;
$runnable = $runnable && $r;
$a[$k] = $value;
}
return [$a, $explainable, $runnable];
}
if (\is_resource($var)) {
return [\sprintf('/* Resource(%s) */', get_resource_type($var)), false, false];
}
return [$var, true, true];
}
}

View File

@@ -0,0 +1,46 @@
<?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\DataCollector;
final class ObjectParameter
{
private bool $stringable;
private string $class;
public function __construct(
private readonly object $object,
private readonly ?\Throwable $error,
) {
$this->stringable = $this->object instanceof \Stringable;
$this->class = $object::class;
}
public function getObject(): object
{
return $this->object;
}
public function getError(): ?\Throwable
{
return $this->error;
}
public function isStringable(): bool
{
return $this->stringable;
}
public function getClass(): string
{
return $this->class;
}
}

View File

@@ -0,0 +1,430 @@
<?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\DependencyInjection;
use Symfony\Component\DependencyInjection\Alias;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
trigger_deprecation('symfony/doctrine-bridge', '7.4', 'The "%s" class is deprecated, the code is incorporated into the extension classes of Doctrine bundles.', AbstractDoctrineExtension::class);
/**
* This abstract classes groups common code that Doctrine Object Manager extensions (ORM, MongoDB, CouchDB) need.
*
* @author Benjamin Eberlei <kontakt@beberlei.de>
*
* @deprecated since Symfony 7.4, the code is incorporated into the extension classes of Doctrine bundles
*/
abstract class AbstractDoctrineExtension extends Extension
{
/**
* Used inside metadata driver method to simplify aggregation of data.
*/
protected array $aliasMap = [];
/**
* Used inside metadata driver method to simplify aggregation of data.
*/
protected array $drivers = [];
/**
* @param array $objectManager A configured object manager
*
* @throws \InvalidArgumentException
*/
protected function loadMappingInformation(array $objectManager, ContainerBuilder $container): void
{
if ($objectManager['auto_mapping']) {
// automatically register bundle mappings
foreach (array_keys($container->getParameter('kernel.bundles')) as $bundle) {
if (!isset($objectManager['mappings'][$bundle])) {
$objectManager['mappings'][$bundle] = [
'mapping' => true,
'is_bundle' => true,
];
}
}
}
foreach ($objectManager['mappings'] as $mappingName => $mappingConfig) {
if (null !== $mappingConfig && false === $mappingConfig['mapping']) {
continue;
}
$mappingConfig = array_replace([
'dir' => false,
'type' => false,
'prefix' => false,
], (array) $mappingConfig);
$mappingConfig['dir'] = $container->getParameterBag()->resolveValue($mappingConfig['dir']);
// a bundle configuration is detected by realizing that the specified dir is not absolute and existing
if (!isset($mappingConfig['is_bundle'])) {
$mappingConfig['is_bundle'] = !is_dir($mappingConfig['dir']);
}
if ($mappingConfig['is_bundle']) {
$bundle = null;
$bundleMetadata = null;
foreach ($container->getParameter('kernel.bundles') as $name => $class) {
if ($mappingName === $name) {
$bundle = new \ReflectionClass($class);
$bundleMetadata = $container->getParameter('kernel.bundles_metadata')[$name];
break;
}
}
if (null === $bundle) {
throw new \InvalidArgumentException(\sprintf('Bundle "%s" does not exist or it is not enabled.', $mappingName));
}
$mappingConfig = $this->getMappingDriverBundleConfigDefaults($mappingConfig, $bundle, $container, $bundleMetadata['path']);
if (!$mappingConfig) {
continue;
}
} elseif (!$mappingConfig['type']) {
$mappingConfig['type'] = 'attribute';
}
$this->assertValidMappingConfiguration($mappingConfig, $objectManager['name']);
$this->setMappingDriverConfig($mappingConfig, $mappingName);
$this->setMappingDriverAlias($mappingConfig, $mappingName);
}
}
/**
* Register the alias for this mapping driver.
*
* Aliases can be used in the Query languages of all the Doctrine object managers to simplify writing tasks.
*/
protected function setMappingDriverAlias(array $mappingConfig, string $mappingName): void
{
if (isset($mappingConfig['alias'])) {
$this->aliasMap[$mappingConfig['alias']] = $mappingConfig['prefix'];
} else {
$this->aliasMap[$mappingName] = $mappingConfig['prefix'];
}
}
/**
* Register the mapping driver configuration for later use with the object managers metadata driver chain.
*
* @throws \InvalidArgumentException
*/
protected function setMappingDriverConfig(array $mappingConfig, string $mappingName): void
{
$mappingDirectory = $mappingConfig['dir'];
if (!is_dir($mappingDirectory)) {
throw new \InvalidArgumentException(\sprintf('Invalid Doctrine mapping path given. Cannot load Doctrine mapping/bundle named "%s".', $mappingName));
}
$this->drivers[$mappingConfig['type']][$mappingConfig['prefix']] = realpath($mappingDirectory) ?: $mappingDirectory;
}
/**
* If this is a bundle controlled mapping all the missing information can be autodetected by this method.
*
* Returns false when autodetection failed, an array of the completed information otherwise.
*/
protected function getMappingDriverBundleConfigDefaults(array $bundleConfig, \ReflectionClass $bundle, ContainerBuilder $container, ?string $bundleDir = null): array|false
{
$bundleClassDir = \dirname($bundle->getFileName());
$bundleDir ??= $bundleClassDir;
if (!$bundleConfig['type']) {
$bundleConfig['type'] = $this->detectMetadataDriver($bundleDir, $container);
if (!$bundleConfig['type'] && $bundleDir !== $bundleClassDir) {
$bundleConfig['type'] = $this->detectMetadataDriver($bundleClassDir, $container);
}
}
if (!$bundleConfig['type']) {
// skip this bundle, no mapping information was found.
return false;
}
if (!$bundleConfig['dir']) {
if (\in_array($bundleConfig['type'], ['staticphp', 'attribute'], true)) {
$bundleConfig['dir'] = $bundleClassDir.'/'.$this->getMappingObjectDefaultName();
} else {
$bundleConfig['dir'] = $bundleDir.'/'.$this->getMappingResourceConfigDirectory($bundleDir);
}
} else {
$bundleConfig['dir'] = $bundleDir.'/'.$bundleConfig['dir'];
}
if (!$bundleConfig['prefix']) {
$bundleConfig['prefix'] = $bundle->getNamespaceName().'\\'.$this->getMappingObjectDefaultName();
}
return $bundleConfig;
}
/**
* Register all the collected mapping information with the object manager by registering the appropriate mapping drivers.
*/
protected function registerMappingDrivers(array $objectManager, ContainerBuilder $container): void
{
// configure metadata driver for each bundle based on the type of mapping files found
if ($container->hasDefinition($this->getObjectManagerElementName($objectManager['name'].'_metadata_driver'))) {
$chainDriverDef = $container->getDefinition($this->getObjectManagerElementName($objectManager['name'].'_metadata_driver'));
} else {
$chainDriverDef = new Definition($this->getMetadataDriverClass('driver_chain'));
}
foreach ($this->drivers as $driverType => $driverPaths) {
$mappingService = $this->getObjectManagerElementName($objectManager['name'].'_'.$driverType.'_metadata_driver');
if ($container->hasDefinition($mappingService)) {
$mappingDriverDef = $container->getDefinition($mappingService);
$args = $mappingDriverDef->getArguments();
$args[0] = array_merge(array_values($driverPaths), $args[0]);
$mappingDriverDef->setArguments($args);
} else {
$mappingDriverDef = new Definition($this->getMetadataDriverClass($driverType), [
array_values($driverPaths),
]);
}
if (str_contains($mappingDriverDef->getClass(), 'yml') || str_contains($mappingDriverDef->getClass(), 'xml')
|| str_contains($mappingDriverDef->getClass(), 'Yaml') || str_contains($mappingDriverDef->getClass(), 'Xml')
) {
$mappingDriverDef->setArguments([array_flip($driverPaths)]);
$mappingDriverDef->addMethodCall('setGlobalBasename', ['mapping']);
}
$container->setDefinition($mappingService, $mappingDriverDef);
foreach ($driverPaths as $prefix => $driverPath) {
$chainDriverDef->addMethodCall('addDriver', [new Reference($mappingService), $prefix]);
}
}
$container->setDefinition($this->getObjectManagerElementName($objectManager['name'].'_metadata_driver'), $chainDriverDef);
}
/**
* Assertion if the specified mapping information is valid.
*
* @throws \InvalidArgumentException
*/
protected function assertValidMappingConfiguration(array $mappingConfig, string $objectManagerName): void
{
if (!$mappingConfig['type'] || !$mappingConfig['dir'] || !$mappingConfig['prefix']) {
throw new \InvalidArgumentException(\sprintf('Mapping definitions for Doctrine manager "%s" require at least the "type", "dir" and "prefix" options.', $objectManagerName));
}
if (!is_dir($mappingConfig['dir'])) {
throw new \InvalidArgumentException(\sprintf('Specified non-existing directory "%s" as Doctrine mapping source.', $mappingConfig['dir']));
}
if (!\in_array($mappingConfig['type'], ['xml', 'yml', 'php', 'staticphp', 'attribute'], true)) {
throw new \InvalidArgumentException(\sprintf('Can only configure "xml", "yml", "php", "staticphp" or "attribute" through the DoctrineBundle. Use your own bundle to configure other metadata drivers. You can register them by adding a new driver to the "%s" service definition.', $this->getObjectManagerElementName($objectManagerName.'_metadata_driver')));
}
}
/**
* Detects what metadata driver to use for the supplied directory.
*/
protected function detectMetadataDriver(string $dir, ContainerBuilder $container): ?string
{
$configPath = $this->getMappingResourceConfigDirectory($dir);
$extension = $this->getMappingResourceExtension();
if (glob($dir.'/'.$configPath.'/*.'.$extension.'.xml', \GLOB_NOSORT)) {
$driver = 'xml';
} elseif (glob($dir.'/'.$configPath.'/*.'.$extension.'.yml', \GLOB_NOSORT)) {
$driver = 'yml';
} elseif (glob($dir.'/'.$configPath.'/*.'.$extension.'.php', \GLOB_NOSORT)) {
$driver = 'php';
} else {
// add the closest existing directory as a resource
$resource = $dir.'/'.$configPath;
while (!is_dir($resource)) {
$resource = \dirname($resource);
}
$container->fileExists($resource, false);
if ($container->fileExists($dir.'/'.$this->getMappingObjectDefaultName(), false)) {
return 'attribute';
}
return null;
}
$container->fileExists($dir.'/'.$configPath, false);
return $driver;
}
/**
* Loads a configured object manager metadata, query or result cache driver.
*
* @throws \InvalidArgumentException in case of unknown driver type
*/
protected function loadObjectManagerCacheDriver(array $objectManager, ContainerBuilder $container, string $cacheName): void
{
$this->loadCacheDriver($cacheName, $objectManager['name'], $objectManager[$cacheName.'_driver'], $container);
}
/**
* Loads a cache driver.
*
* @throws \InvalidArgumentException
*/
protected function loadCacheDriver(string $cacheName, string $objectManagerName, array $cacheDriver, ContainerBuilder $container): string
{
$cacheDriverServiceId = $this->getObjectManagerElementName($objectManagerName.'_'.$cacheName);
switch ($cacheDriver['type']) {
case 'service':
$container->setAlias($cacheDriverServiceId, new Alias($cacheDriver['id'], false));
return $cacheDriverServiceId;
case 'memcached':
$memcachedClass = !empty($cacheDriver['class']) ? $cacheDriver['class'] : '%'.$this->getObjectManagerElementName('cache.memcached.class').'%';
$memcachedInstanceClass = !empty($cacheDriver['instance_class']) ? $cacheDriver['instance_class'] : '%'.$this->getObjectManagerElementName('cache.memcached_instance.class').'%';
$memcachedHost = !empty($cacheDriver['host']) ? $cacheDriver['host'] : '%'.$this->getObjectManagerElementName('cache.memcached_host').'%';
$memcachedPort = !empty($cacheDriver['port']) ? $cacheDriver['port'] : '%'.$this->getObjectManagerElementName('cache.memcached_port').'%';
$cacheDef = new Definition($memcachedClass);
$memcachedInstance = new Definition($memcachedInstanceClass);
$memcachedInstance->addMethodCall('addServer', [
$memcachedHost, $memcachedPort,
]);
$container->setDefinition($this->getObjectManagerElementName(\sprintf('%s_memcached_instance', $objectManagerName)), $memcachedInstance);
$cacheDef->addMethodCall('setMemcached', [new Reference($this->getObjectManagerElementName(\sprintf('%s_memcached_instance', $objectManagerName)))]);
break;
case 'redis':
case 'valkey':
$redisClass = !empty($cacheDriver['class']) ? $cacheDriver['class'] : '%'.$this->getObjectManagerElementName('cache.redis.class').'%';
$redisInstanceClass = !empty($cacheDriver['instance_class']) ? $cacheDriver['instance_class'] : '%'.$this->getObjectManagerElementName('cache.redis_instance.class').'%';
$redisHost = !empty($cacheDriver['host']) ? $cacheDriver['host'] : '%'.$this->getObjectManagerElementName('cache.redis_host').'%';
$redisPort = !empty($cacheDriver['port']) ? $cacheDriver['port'] : '%'.$this->getObjectManagerElementName('cache.redis_port').'%';
$cacheDef = new Definition($redisClass);
$redisInstance = new Definition($redisInstanceClass);
$redisInstance->addMethodCall('connect', [
$redisHost, $redisPort,
]);
$container->setDefinition($this->getObjectManagerElementName(\sprintf('%s_redis_instance', $objectManagerName)), $redisInstance);
$cacheDef->addMethodCall('setRedis', [new Reference($this->getObjectManagerElementName(\sprintf('%s_redis_instance', $objectManagerName)))]);
break;
case 'apc':
case 'apcu':
case 'array':
case 'xcache':
case 'wincache':
case 'zenddata':
$cacheDef = new Definition('%'.$this->getObjectManagerElementName(\sprintf('cache.%s.class', $cacheDriver['type'])).'%');
break;
default:
throw new \InvalidArgumentException(\sprintf('"%s" is an unrecognized Doctrine cache driver.', $cacheDriver['type']));
}
if (!isset($cacheDriver['namespace'])) {
// generate a unique namespace for the given application
if ($container->hasParameter('cache.prefix.seed')) {
$seed = $container->getParameterBag()->resolveValue($container->getParameter('cache.prefix.seed'));
} else {
$seed = '_'.$container->getParameter('kernel.project_dir');
$seed .= '.'.$container->getParameter('kernel.container_class');
}
$namespace = 'sf_'.$this->getMappingResourceExtension().'_'.$objectManagerName.'_'.ContainerBuilder::hash($seed);
$cacheDriver['namespace'] = $namespace;
}
$cacheDef->addMethodCall('setNamespace', [$cacheDriver['namespace']]);
$container->setDefinition($cacheDriverServiceId, $cacheDef);
return $cacheDriverServiceId;
}
/**
* Returns a modified version of $managerConfigs.
*
* The manager called $autoMappedManager will map all bundles that are not mapped by other managers.
*/
protected function fixManagersAutoMappings(array $managerConfigs, array $bundles): array
{
if ($autoMappedManager = $this->validateAutoMapping($managerConfigs)) {
foreach (array_keys($bundles) as $bundle) {
foreach ($managerConfigs as $manager) {
if (isset($manager['mappings'][$bundle])) {
continue 2;
}
}
$managerConfigs[$autoMappedManager]['mappings'][$bundle] = [
'mapping' => true,
'is_bundle' => true,
];
}
$managerConfigs[$autoMappedManager]['auto_mapping'] = false;
}
return $managerConfigs;
}
/**
* Prefixes the relative dependency injection container path with the object manager prefix.
*
* @example $name is 'entity_manager' then the result would be 'doctrine.orm.entity_manager'
*/
abstract protected function getObjectManagerElementName(string $name): string;
/**
* Noun that describes the mapped objects such as Entity or Document.
*
* Will be used for autodetection of persistent objects directory.
*/
abstract protected function getMappingObjectDefaultName(): string;
/**
* Relative path from the bundle root to the directory where mapping files reside.
*/
abstract protected function getMappingResourceConfigDirectory(?string $bundleDir = null): string;
/**
* Extension used by the mapping files.
*/
abstract protected function getMappingResourceExtension(): string;
/**
* The class name used by the various mapping drivers.
*/
abstract protected function getMetadataDriverClass(string $driverType): string;
/**
* Search for a manager that is declared as 'auto_mapping' = true.
*
* @throws \LogicException
*/
private function validateAutoMapping(array $managerConfigs): ?string
{
$autoMappedManager = null;
foreach ($managerConfigs as $name => $manager) {
if (!$manager['auto_mapping']) {
continue;
}
if (null !== $autoMappedManager) {
throw new \LogicException(\sprintf('You cannot enable "auto_mapping" on more than one manager at the same time (found in "%s" and "%s"").', $autoMappedManager, $name));
}
$autoMappedManager = $name;
}
return $autoMappedManager;
}
}

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\DependencyInjection\CompilerPass;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* Registers additional validators.
*
* @author Benjamin Eberlei <kontakt@beberlei.de>
*/
class DoctrineValidationPass implements CompilerPassInterface
{
public function __construct(
private readonly string $managerType,
) {
}
public function process(ContainerBuilder $container): void
{
$this->updateValidatorMappingFiles($container, 'xml', 'xml');
$this->updateValidatorMappingFiles($container, 'yaml', 'yml');
}
/**
* Gets the validation mapping files for the format and extends them with
* files matching a doctrine search pattern (Resources/config/validation.orm.xml).
*/
private function updateValidatorMappingFiles(ContainerBuilder $container, string $mapping, string $extension): void
{
if (!$container->hasParameter('validator.mapping.loader.'.$mapping.'_files_loader.mapping_files')) {
return;
}
$files = $container->getParameter('validator.mapping.loader.'.$mapping.'_files_loader.mapping_files');
$validationPath = '/config/validation.'.$this->managerType.'.'.$extension;
foreach ($container->getParameter('kernel.bundles_metadata') as $bundle) {
if ($container->fileExists($file = $bundle['path'].'/Resources'.$validationPath) || $container->fileExists($file = $bundle['path'].$validationPath)) {
$files[] = $file;
}
}
$container->setParameter('validator.mapping.loader.'.$mapping.'_files_loader.mapping_files', $files);
}
}

View File

@@ -0,0 +1,41 @@
<?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\DependencyInjection\CompilerPass;
use Symfony\Bridge\Doctrine\Types\DatePointType;
use Symfony\Bridge\Doctrine\Types\DayPointType;
use Symfony\Bridge\Doctrine\Types\TimePointType;
use Symfony\Component\Clock\DatePoint;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
final class RegisterDatePointTypePass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!class_exists(DatePoint::class)) {
return;
}
if (!$container->hasParameter('doctrine.dbal.connection_factory.types')) {
return;
}
$types = $container->getParameter('doctrine.dbal.connection_factory.types');
$types['date_point'] ??= ['class' => DatePointType::class];
$types['day_point'] ??= ['class' => DayPointType::class];
$types['time_point'] ??= ['class' => TimePointType::class];
$container->setParameter('doctrine.dbal.connection_factory.types', $types);
}
}

View File

@@ -0,0 +1,144 @@
<?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\DependencyInjection\CompilerPass;
use Symfony\Bridge\Doctrine\ContainerAwareEventManager;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Exception\RuntimeException;
use Symfony\Component\DependencyInjection\Reference;
/**
* Registers event listeners to the available doctrine connections.
*
* @author Jeremy Mikola <jmikola@gmail.com>
* @author Alexander <iam.asm89@gmail.com>
* @author David Maicher <mail@dmaicher.de>
*/
class RegisterEventListenersAndSubscribersPass implements CompilerPassInterface
{
private array $connections;
/**
* @var array<string, Definition>
*/
private array $eventManagers = [];
/**
* @param string $managerTemplate sprintf() template for generating the event
* manager's service ID for a connection name
* @param string $tagPrefix Tag prefix for listeners
*/
public function __construct(
private readonly string $connectionsParameter,
private readonly string $managerTemplate,
private readonly string $tagPrefix,
) {
}
public function process(ContainerBuilder $container): void
{
if (!$container->hasParameter($this->connectionsParameter)) {
return;
}
$this->connections = $container->getParameter($this->connectionsParameter);
$listenerRefs = $this->addTaggedServices($container);
// replace service container argument of event managers with smaller service locator
// so services can even remain private
foreach ($listenerRefs as $connection => $refs) {
$this->getEventManagerDef($container, $connection)
->replaceArgument(0, ServiceLocatorTagPass::register($container, $refs));
}
}
private function addTaggedServices(ContainerBuilder $container): array
{
$listenerRefs = [];
$managerDefs = [];
foreach ($this->findAndSortTags($container) as [$id, $tag]) {
$connections = isset($tag['connection'])
? [$container->getParameterBag()->resolveValue($tag['connection'])]
: array_keys($this->connections);
if (!isset($tag['event'])) {
throw new InvalidArgumentException(\sprintf('Doctrine event listener "%s" must specify the "event" attribute.', $id));
}
foreach ($connections as $con) {
if (!isset($this->connections[$con])) {
throw new RuntimeException(\sprintf('The Doctrine connection "%s" referenced in service "%s" does not exist. Available connections names: "%s".', $con, $id, implode('", "', array_keys($this->connections))));
}
if (!isset($managerDefs[$con])) {
$managerDef = $parentDef = $this->getEventManagerDef($container, $con);
while (!$parentDef->getClass() && $parentDef instanceof ChildDefinition) {
$parentDef = $container->findDefinition($parentDef->getParent());
}
$managerClass = $container->getParameterBag()->resolveValue($parentDef->getClass());
$managerDefs[$con] = [$managerDef, $managerClass];
} else {
[$managerDef, $managerClass] = $managerDefs[$con];
}
if (ContainerAwareEventManager::class === $managerClass) {
$refs = $managerDef->getArguments()[1] ?? [];
$listenerRefs[$con][$id] = new Reference($id);
$refs[] = [[$tag['event']], $id];
$managerDef->setArgument(1, $refs);
} else {
$managerDef->addMethodCall('addEventListener', [[$tag['event']], new Reference($id)]);
}
}
}
return $listenerRefs;
}
private function getEventManagerDef(ContainerBuilder $container, string $name): Definition
{
if (!isset($this->eventManagers[$name])) {
$this->eventManagers[$name] = $container->getDefinition(\sprintf($this->managerTemplate, $name));
}
return $this->eventManagers[$name];
}
/**
* Finds and orders all service tags with the given name by their priority.
*
* The order of additions must be respected for services having the same priority,
* and knowing that the \SplPriorityQueue class does not respect the FIFO method,
* we should not use this class.
*
* @see https://bugs.php.net/53710
* @see https://bugs.php.net/60926
*/
private function findAndSortTags(ContainerBuilder $container): array
{
$sortedTags = [];
foreach ($container->findTaggedServiceIds($this->tagPrefix.'.event_listener', true) as $serviceId => $tags) {
foreach ($tags as $attributes) {
$priority = $attributes['priority'] ?? 0;
$sortedTags[$priority][] = [$serviceId, $attributes];
}
}
krsort($sortedTags);
return array_merge(...$sortedTags);
}
}

View File

@@ -0,0 +1,169 @@
<?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\DependencyInjection\CompilerPass;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Reference;
/**
* Base class for the doctrine bundles to provide a compiler pass class that
* helps to register doctrine mappings.
*
* The compiler pass is meant to register the mappings with the metadata
* chain driver corresponding to one of the object managers.
*
* For concrete implementations, see the RegisterXyMappingsPass classes
* in the DoctrineBundle resp.
* DoctrineMongodbBundle, DoctrineCouchdbBundle and DoctrinePhpcrBundle.
*
* @author David Buchmann <david@liip.ch>
*/
abstract class RegisterMappingsPass implements CompilerPassInterface
{
/**
* The $managerParameters is an ordered list of container parameters that could provide the
* name of the manager to register these namespaces and alias on. The first non-empty name
* is used, the others skipped.
*
* The $aliasMap parameter can be used to define bundle namespace shortcuts like the
* DoctrineBundle provides automatically for objects in the default Entity/Document folder.
*
* @param Definition|Reference $driver Driver DI definition or reference
* @param string[] $namespaces List of namespaces handled by $driver
* @param string[] $managerParameters list of container parameters that could
* hold the manager name
* @param string $driverPattern Pattern for the metadata chain driver service ids (e.g. "doctrine.orm.%s_metadata_driver")
* @param string|false $enabledParameter Service container parameter that must be
* present to enable the mapping (regardless of the
* parameter value). Pass false to not do any check.
* @param string $configurationPattern Pattern for the Configuration service name,
* for example 'doctrine.orm.%s_configuration'.
* @param string $registerAliasMethodName Method name to call on the configuration service. This
* depends on the Doctrine implementation.
* For example addEntityNamespace.
* @param string[] $aliasMap Map of alias to namespace
*/
public function __construct(
protected Definition|Reference $driver,
protected array $namespaces,
protected array $managerParameters,
protected string $driverPattern,
protected string|false $enabledParameter = false,
private readonly string $configurationPattern = '',
private readonly string $registerAliasMethodName = '',
private readonly array $aliasMap = [],
) {
if ($aliasMap && (!$configurationPattern || !$registerAliasMethodName)) {
throw new \InvalidArgumentException('configurationPattern and registerAliasMethodName are required to register namespace alias.');
}
}
/**
* Register mappings and alias with the metadata drivers.
*/
public function process(ContainerBuilder $container): void
{
if (!$this->enabled($container)) {
return;
}
$mappingDriverDef = $this->getDriver($container);
$chainDriverDefService = $this->getChainDriverServiceName($container);
// Definition for a Doctrine\Persistence\Mapping\Driver\MappingDriverChain
$chainDriverDef = $container->getDefinition($chainDriverDefService);
foreach ($this->namespaces as $namespace) {
$chainDriverDef->addMethodCall('addDriver', [$mappingDriverDef, $namespace]);
}
if (!\count($this->aliasMap)) {
return;
}
$configurationServiceName = $this->getConfigurationServiceName($container);
// Definition of the Doctrine\...\Configuration class specific to the Doctrine flavour.
$configurationServiceDefinition = $container->getDefinition($configurationServiceName);
foreach ($this->aliasMap as $alias => $namespace) {
$configurationServiceDefinition->addMethodCall($this->registerAliasMethodName, [$alias, $namespace]);
}
}
/**
* Get the service name of the metadata chain driver that the mappings
* should be registered with.
*
* @throws InvalidArgumentException if non of the managerParameters has a
* non-empty value
*/
protected function getChainDriverServiceName(ContainerBuilder $container): string
{
return \sprintf($this->driverPattern, $this->getManagerName($container));
}
/**
* Create the service definition for the metadata driver.
*
* @param ContainerBuilder $container Passed on in case an extending class
* needs access to the container
*/
protected function getDriver(ContainerBuilder $container): Definition|Reference
{
return $this->driver;
}
/**
* Get the service name from the pattern and the configured manager name.
*
* @throws InvalidArgumentException if none of the managerParameters has a
* non-empty value
*/
private function getConfigurationServiceName(ContainerBuilder $container): string
{
return \sprintf($this->configurationPattern, $this->getManagerName($container));
}
/**
* Determine the manager name.
*
* The default implementation loops over the managerParameters and returns
* the first non-empty parameter.
*
* @throws InvalidArgumentException if none of the managerParameters is found in the container
*/
private function getManagerName(ContainerBuilder $container): string
{
foreach ($this->managerParameters as $param) {
if ($container->hasParameter($param)) {
$name = $container->getParameter($param);
if ($name) {
return $name;
}
}
}
throw new InvalidArgumentException(\sprintf('Could not find the manager name parameter in the container. Tried the following parameter names: "%s".', implode('", "', $this->managerParameters)));
}
/**
* Determine whether this mapping should be activated or not. This allows
* to take this decision with the container builder available.
*
* This default implementation checks if the class has the enabledParameter
* configured and if so if that parameter is present in the container.
*/
protected function enabled(ContainerBuilder $container): bool
{
return !$this->enabledParameter || $container->hasParameter($this->enabledParameter);
}
}

View File

@@ -0,0 +1,44 @@
<?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\DependencyInjection\CompilerPass;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Uid\AbstractUid;
final class RegisterUidTypePass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!class_exists(AbstractUid::class)) {
return;
}
if (!$container->hasParameter('doctrine.dbal.connection_factory.types')) {
return;
}
$typeDefinition = $container->getParameter('doctrine.dbal.connection_factory.types');
if (!isset($typeDefinition['uuid'])) {
$typeDefinition['uuid'] = ['class' => UuidType::class];
}
if (!isset($typeDefinition['ulid'])) {
$typeDefinition['ulid'] = ['class' => UlidType::class];
}
$container->setParameter('doctrine.dbal.connection_factory.types', $typeDefinition);
}
}

View File

@@ -0,0 +1,64 @@
<?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\DependencyInjection\Security\UserProvider;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\UserProvider\UserProviderFactoryInterface;
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* EntityFactory creates services for Doctrine user provider.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Christophe Coevoet <stof@notk.org>
*
* @final
*/
class EntityFactory implements UserProviderFactoryInterface
{
public function __construct(
private readonly string $key,
private readonly string $providerId,
) {
}
public function create(ContainerBuilder $container, string $id, array $config): void
{
$container
->setDefinition($id, new ChildDefinition($this->providerId))
->addArgument($config['class'])
->addArgument($config['property'])
->addArgument($config['manager_name'])
;
}
public function getKey(): string
{
return $this->key;
}
public function addConfiguration(NodeDefinition $node): void
{
$node
->children()
->scalarNode('class')
->isRequired()
->info('The full entity class name of your user class.')
->cannotBeEmpty()
->end()
->scalarNode('property')->defaultNull()->end()
->scalarNode('manager_name')->defaultNull()->end()
->end()
;
}
}

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()];
}
}

View File

@@ -0,0 +1,43 @@
<?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\IdGenerator;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Id\AbstractIdGenerator;
use Symfony\Component\Uid\Factory\UlidFactory;
use Symfony\Component\Uid\Ulid;
final class UlidGenerator extends AbstractIdGenerator
{
public function __construct(
private readonly ?UlidFactory $factory = null,
) {
}
/**
* doctrine/orm < 2.11 BC layer.
*/
public function generate(EntityManager $em, $entity): Ulid
{
return $this->generateId($em, $entity);
}
public function generateId(EntityManagerInterface $em, $entity): Ulid
{
if ($this->factory) {
return $this->factory->create();
}
return new Ulid();
}
}

View File

@@ -0,0 +1,81 @@
<?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\IdGenerator;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Id\AbstractIdGenerator;
use Symfony\Component\Uid\Factory\NameBasedUuidFactory;
use Symfony\Component\Uid\Factory\RandomBasedUuidFactory;
use Symfony\Component\Uid\Factory\TimeBasedUuidFactory;
use Symfony\Component\Uid\Factory\UuidFactory;
use Symfony\Component\Uid\Uuid;
final class UuidGenerator extends AbstractIdGenerator
{
private readonly UuidFactory $protoFactory;
private UuidFactory|NameBasedUuidFactory|RandomBasedUuidFactory|TimeBasedUuidFactory $factory;
private ?string $entityGetter = null;
public function __construct(?UuidFactory $factory = null)
{
$this->protoFactory = $this->factory = $factory ?? new UuidFactory();
}
/**
* doctrine/orm < 2.11 BC layer.
*/
public function generate(EntityManager $em, $entity): Uuid
{
return $this->generateId($em, $entity);
}
public function generateId(EntityManagerInterface $em, $entity): Uuid
{
if (null !== $this->entityGetter) {
if (\is_callable([$entity, $this->entityGetter])) {
return $this->factory->create($entity->{$this->entityGetter}());
}
return $this->factory->create($entity->{$this->entityGetter});
}
return $this->factory->create();
}
public function nameBased(string $entityGetter, Uuid|string|null $namespace = null): static
{
$clone = clone $this;
$clone->factory = $clone->protoFactory->nameBased($namespace);
$clone->entityGetter = $entityGetter;
return $clone;
}
public function randomBased(): static
{
$clone = clone $this;
$clone->factory = $clone->protoFactory->randomBased();
$clone->entityGetter = null;
return $clone;
}
public function timeBased(Uuid|string|null $node = null): static
{
$clone = clone $this;
$clone->factory = $clone->protoFactory->timeBased($node);
$clone->entityGetter = null;
return $clone;
}
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2004-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,120 @@
<?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;
use Doctrine\Persistence\AbstractManagerRegistry;
use ProxyManager\Proxy\GhostObjectInterface;
use ProxyManager\Proxy\LazyLoadingInterface;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\VarExporter\LazyObjectInterface;
/**
* References Doctrine connections and entity/document managers.
*
* @author Lukas Kahwe Smith <smith@pooteeweet.org>
*/
abstract class ManagerRegistry extends AbstractManagerRegistry
{
protected Container $container;
protected function getService($name): object
{
return $this->container->get($name);
}
protected function resetService($name): void
{
if (!$this->container->initialized($name)) {
return;
}
$manager = $this->container->get($name);
if ($manager instanceof LazyObjectInterface) {
if (!$manager->resetLazyObject()) {
throw new \LogicException(\sprintf('Resetting a non-lazy manager service is not supported. Declare the "%s" service as lazy.', $name));
}
return;
}
if (\PHP_VERSION_ID < 80400) {
if (!$manager instanceof LazyLoadingInterface) {
throw new \LogicException(\sprintf('Resetting a non-lazy manager service is not supported. Declare the "%s" service as lazy.', $name));
}
trigger_deprecation('symfony/doctrine-bridge', '7.3', 'Support for proxy-manager is deprecated.');
if ($manager instanceof GhostObjectInterface) {
throw new \LogicException('Resetting a lazy-ghost-object manager service is not supported.');
}
$manager->setProxyInitializer(\Closure::bind(
function (&$wrappedInstance, LazyLoadingInterface $manager) use ($name) {
$name = $this->aliases[$name] ?? $name;
$wrappedInstance = match (true) {
isset($this->fileMap[$name]) => $this->load($this->fileMap[$name], false),
!$method = $this->methodMap[$name] ?? null => throw new \LogicException(\sprintf('The "%s" service is synthetic and cannot be reset.', $name)),
(new \ReflectionMethod($this, $method))->isStatic() => $this->{$method}($this, false),
default => $this->{$method}(false),
};
$manager->setProxyInitializer(null);
return true;
},
$this->container,
Container::class
));
return;
}
$r = new \ReflectionClass($manager);
if ($r->isUninitializedLazyObject($manager)) {
return;
}
$asProxy = $r->initializeLazyObject($manager) !== $manager;
$initializer = \Closure::bind(
function ($manager) use ($name, $asProxy) {
$name = $this->aliases[$name] ?? $name;
if ($asProxy) {
$manager = false;
}
$manager = match (true) {
isset($this->fileMap[$name]) => $this->load($this->fileMap[$name], $manager),
!$method = $this->methodMap[$name] ?? null => throw new \LogicException(\sprintf('The "%s" service is synthetic and cannot be reset.', $name)),
(new \ReflectionMethod($this, $method))->isStatic() => $this->{$method}($this, $manager),
default => $this->{$method}($manager),
};
if ($asProxy) {
return $manager;
}
},
$this->container,
Container::class
);
try {
if ($asProxy) {
$r->resetAsLazyProxy($manager, $initializer);
} else {
$r->resetAsLazyGhost($manager, $initializer);
}
} catch (\Error $e) {
if (__FILE__ !== $e->getFile()) {
throw $e;
}
throw new \LogicException(\sprintf('Resetting a non-lazy manager service is not supported. Declare the "%s" service as lazy.', $name), 0, $e);
}
}
}

View File

@@ -0,0 +1,46 @@
<?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\Messenger;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
/**
* @author Konstantin Myakshin <molodchick@gmail.com>
*
* @internal
*/
abstract class AbstractDoctrineMiddleware implements MiddlewareInterface
{
public function __construct(
protected ManagerRegistry $managerRegistry,
protected ?string $entityManagerName = null,
) {
}
final public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
try {
$entityManager = $this->managerRegistry->getManager($this->entityManagerName);
} catch (\InvalidArgumentException $e) {
throw new UnrecoverableMessageHandlingException($e->getMessage(), 0, $e);
}
return $this->handleForManager($entityManager, $envelope, $stack);
}
abstract protected function handleForManager(EntityManagerInterface $entityManager, Envelope $envelope, StackInterface $stack): Envelope;
}

View File

@@ -0,0 +1,55 @@
<?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\Messenger;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent;
/**
* Clears entity managers between messages being handled to avoid outdated data.
*
* @author Ryan Weaver <ryan@symfonycasts.com>
*/
class DoctrineClearEntityManagerWorkerSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly ManagerRegistry $managerRegistry,
) {
}
public function onWorkerMessageHandled(): void
{
$this->clearEntityManagers();
}
public function onWorkerMessageFailed(): void
{
$this->clearEntityManagers();
}
public static function getSubscribedEvents(): array
{
return [
WorkerMessageHandledEvent::class => 'onWorkerMessageHandled',
WorkerMessageFailedEvent::class => 'onWorkerMessageFailed',
];
}
private function clearEntityManagers(): void
{
foreach ($this->managerRegistry->getManagers() as $manager) {
$manager->clear();
}
}
}

View File

@@ -0,0 +1,38 @@
<?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\Messenger;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp;
/**
* Closes connection and therefore saves number of connections.
*
* @author Fuong <insidestyles@gmail.com>
*/
class DoctrineCloseConnectionMiddleware extends AbstractDoctrineMiddleware
{
protected function handleForManager(EntityManagerInterface $entityManager, Envelope $envelope, StackInterface $stack): Envelope
{
try {
$connection = $entityManager->getConnection();
return $stack->next()->handle($envelope, $stack);
} finally {
if (null !== $envelope->last(ConsumedByWorkerStamp::class)) {
$connection->close();
}
}
}
}

View File

@@ -0,0 +1,57 @@
<?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\Messenger;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\StackInterface;
/**
* Middleware to log when transaction has been left open.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*/
class DoctrineOpenTransactionLoggerMiddleware extends AbstractDoctrineMiddleware
{
private bool $isHandling = false;
public function __construct(
ManagerRegistry $managerRegistry,
?string $entityManagerName = null,
private readonly ?LoggerInterface $logger = null,
) {
parent::__construct($managerRegistry, $entityManagerName);
}
protected function handleForManager(EntityManagerInterface $entityManager, Envelope $envelope, StackInterface $stack): Envelope
{
if ($this->isHandling) {
return $stack->next()->handle($envelope, $stack);
}
$this->isHandling = true;
$initialTransactionLevel = $entityManager->getConnection()->getTransactionNestingLevel();
try {
return $stack->next()->handle($envelope, $stack);
} finally {
if ($entityManager->getConnection()->getTransactionNestingLevel() > $initialTransactionLevel) {
$this->logger?->error('A handler opened a transaction but did not close it.', [
'message' => $envelope->getMessage(),
]);
}
$this->isHandling = false;
}
}
}

View File

@@ -0,0 +1,61 @@
<?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\Messenger;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception as DBALException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp;
/**
* Checks whether the connection is still open or reconnects otherwise.
*
* @author Fuong <insidestyles@gmail.com>
*/
class DoctrinePingConnectionMiddleware extends AbstractDoctrineMiddleware
{
protected function handleForManager(EntityManagerInterface $entityManager, Envelope $envelope, StackInterface $stack): Envelope
{
if (null !== $envelope->last(ConsumedByWorkerStamp::class)) {
$this->pingConnection($entityManager);
}
return $stack->next()->handle($envelope, $stack);
}
private function pingConnection(EntityManagerInterface $entityManager): void
{
$connection = $entityManager->getConnection();
try {
$this->executeDummySql($connection);
} catch (DBALException) {
$connection->close();
// Attempt to reestablish the lazy connection by sending another query.
$this->executeDummySql($connection);
}
if (!$entityManager->isOpen()) {
$this->managerRegistry->resetManager($this->entityManagerName);
}
}
/**
* @throws DBALException
*/
private function executeDummySql(Connection $connection): void
{
$connection->executeQuery($connection->getDatabasePlatform()->getDummySelectSQL());
}
}

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\Messenger;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Exception\HandlerFailedException;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;
/**
* Wraps all handlers in a single doctrine transaction.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class DoctrineTransactionMiddleware extends AbstractDoctrineMiddleware
{
protected function handleForManager(EntityManagerInterface $entityManager, Envelope $envelope, StackInterface $stack): Envelope
{
$entityManager->getConnection()->beginTransaction();
$success = false;
try {
$envelope = $stack->next()->handle($envelope, $stack);
$entityManager->flush();
$entityManager->getConnection()->commit();
$success = true;
return $envelope;
} catch (\Throwable $exception) {
if ($exception instanceof HandlerFailedException) {
// Remove all HandledStamp from the envelope so the retry will execute all handlers again.
// When a handler fails, the queries of allegedly successful previous handlers just got rolled back.
throw new HandlerFailedException($exception->getEnvelope()->withoutAll(HandledStamp::class), $exception->getWrappedExceptions());
}
throw $exception;
} finally {
$connection = $entityManager->getConnection();
if (!$success && $connection->isTransactionActive()) {
$connection->rollBack();
}
}
}
}

View File

@@ -0,0 +1,126 @@
<?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\Middleware\Debug;
use Doctrine\DBAL\Driver\Connection as ConnectionInterface;
use Doctrine\DBAL\Driver\Middleware\AbstractConnectionMiddleware;
use Doctrine\DBAL\Driver\Result;
use Symfony\Component\Stopwatch\Stopwatch;
/**
* @author Laurent VOULLEMIER <laurent.voullemier@gmail.com>
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class Connection extends AbstractConnectionMiddleware
{
public function __construct(
ConnectionInterface $connection,
private readonly DebugDataHolder $debugDataHolder,
private readonly ?Stopwatch $stopwatch,
private readonly string $connectionName,
) {
parent::__construct($connection);
}
public function prepare(string $sql): Statement
{
return new Statement(
parent::prepare($sql),
$this->debugDataHolder,
$this->connectionName,
$sql,
$this->stopwatch,
);
}
public function query(string $sql): Result
{
$this->debugDataHolder->addQuery($this->connectionName, $query = new Query($sql));
$this->stopwatch?->start('doctrine', 'doctrine');
$query->start();
try {
return parent::query($sql);
} finally {
$query->stop();
$this->stopwatch?->stop('doctrine');
}
}
public function exec(string $sql): int
{
$this->debugDataHolder->addQuery($this->connectionName, $query = new Query($sql));
$this->stopwatch?->start('doctrine', 'doctrine');
$query->start();
try {
$affectedRows = parent::exec($sql);
} finally {
$query->stop();
$this->stopwatch?->stop('doctrine');
}
return $affectedRows;
}
public function beginTransaction(): void
{
$query = new Query('"START TRANSACTION"');
$this->debugDataHolder->addQuery($this->connectionName, $query);
$this->stopwatch?->start('doctrine', 'doctrine');
$query->start();
try {
parent::beginTransaction();
} finally {
$query->stop();
$this->stopwatch?->stop('doctrine');
}
}
public function commit(): void
{
$query = new Query('"COMMIT"');
$this->debugDataHolder->addQuery($this->connectionName, $query);
$this->stopwatch?->start('doctrine', 'doctrine');
$query->start();
try {
parent::commit();
} finally {
$query->stop();
$this->stopwatch?->stop('doctrine');
}
}
public function rollBack(): void
{
$query = new Query('"ROLLBACK"');
$this->debugDataHolder->addQuery($this->connectionName, $query);
$this->stopwatch?->start('doctrine', 'doctrine');
$query->start();
try {
parent::rollBack();
} finally {
$query->stop();
$this->stopwatch?->stop('doctrine');
}
}
}

View File

@@ -0,0 +1,133 @@
<?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\Middleware\Debug\DBAL3;
use Doctrine\DBAL\Driver\Connection as ConnectionInterface;
use Doctrine\DBAL\Driver\Middleware\AbstractConnectionMiddleware;
use Doctrine\DBAL\Driver\Result;
use Symfony\Bridge\Doctrine\Middleware\Debug\DebugDataHolder;
use Symfony\Bridge\Doctrine\Middleware\Debug\Query;
use Symfony\Component\Stopwatch\Stopwatch;
/**
* @author Laurent VOULLEMIER <laurent.voullemier@gmail.com>
*
* @internal
*/
final class Connection extends AbstractConnectionMiddleware
{
private int $nestingLevel = 0;
public function __construct(
ConnectionInterface $connection,
private readonly DebugDataHolder $debugDataHolder,
private readonly ?Stopwatch $stopwatch,
private readonly string $connectionName,
) {
parent::__construct($connection);
}
public function prepare(string $sql): Statement
{
return new Statement(
parent::prepare($sql),
$this->debugDataHolder,
$this->connectionName,
$sql,
$this->stopwatch,
);
}
public function query(string $sql): Result
{
$this->debugDataHolder->addQuery($this->connectionName, $query = new Query($sql));
$this->stopwatch?->start('doctrine', 'doctrine');
$query->start();
try {
return parent::query($sql);
} finally {
$query->stop();
$this->stopwatch?->stop('doctrine');
}
}
public function exec(string $sql): int
{
$this->debugDataHolder->addQuery($this->connectionName, $query = new Query($sql));
$this->stopwatch?->start('doctrine', 'doctrine');
$query->start();
try {
return parent::exec($sql);
} finally {
$query->stop();
$this->stopwatch?->stop('doctrine');
}
}
public function beginTransaction(): bool
{
$query = null;
if (1 === ++$this->nestingLevel) {
$this->debugDataHolder->addQuery($this->connectionName, $query = new Query('"START TRANSACTION"'));
}
$this->stopwatch?->start('doctrine', 'doctrine');
$query?->start();
try {
return parent::beginTransaction();
} finally {
$query?->stop();
$this->stopwatch?->stop('doctrine');
}
}
public function commit(): bool
{
$query = null;
if (1 === $this->nestingLevel--) {
$this->debugDataHolder->addQuery($this->connectionName, $query = new Query('"COMMIT"'));
}
$this->stopwatch?->start('doctrine', 'doctrine');
$query?->start();
try {
return parent::commit();
} finally {
$query?->stop();
$this->stopwatch?->stop('doctrine');
}
}
public function rollBack(): bool
{
$query = null;
if (1 === $this->nestingLevel--) {
$this->debugDataHolder->addQuery($this->connectionName, $query = new Query('"ROLLBACK"'));
}
$this->stopwatch?->start('doctrine', 'doctrine');
$query?->start();
try {
return parent::rollBack();
} finally {
$query?->stop();
$this->stopwatch?->stop('doctrine');
}
}
}

View File

@@ -0,0 +1,76 @@
<?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\Middleware\Debug\DBAL3;
use Doctrine\DBAL\Driver\Middleware\AbstractStatementMiddleware;
use Doctrine\DBAL\Driver\Result as ResultInterface;
use Doctrine\DBAL\Driver\Statement as StatementInterface;
use Doctrine\DBAL\ParameterType;
use Symfony\Bridge\Doctrine\Middleware\Debug\DebugDataHolder;
use Symfony\Bridge\Doctrine\Middleware\Debug\Query;
use Symfony\Component\Stopwatch\Stopwatch;
/**
* @author Laurent VOULLEMIER <laurent.voullemier@gmail.com>
*
* @internal
*/
final class Statement extends AbstractStatementMiddleware
{
private readonly Query $query;
public function __construct(
StatementInterface $statement,
private readonly DebugDataHolder $debugDataHolder,
private readonly string $connectionName,
string $sql,
private readonly ?Stopwatch $stopwatch = null,
) {
$this->query = new Query($sql);
parent::__construct($statement);
}
public function bindParam($param, &$variable, $type = ParameterType::STRING, $length = null): bool
{
$this->query->setParam($param, $variable, $type);
return parent::bindParam($param, $variable, $type, ...\array_slice(\func_get_args(), 3));
}
public function bindValue($param, $value, $type = ParameterType::STRING): bool
{
$this->query->setValue($param, $value, $type);
return parent::bindValue($param, $value, $type);
}
public function execute($params = null): ResultInterface
{
if (null !== $params) {
$this->query->setValues($params);
}
// clone to prevent variables by reference to change
$this->debugDataHolder->addQuery($this->connectionName, $query = clone $this->query);
$this->stopwatch?->start('doctrine', 'doctrine');
$query->start();
try {
return parent::execute($params);
} finally {
$query->stop();
$this->stopwatch?->stop('doctrine');
}
}
}

View File

@@ -0,0 +1,48 @@
<?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\Middleware\Debug;
/**
* @author Laurent VOULLEMIER <laurent.voullemier@gmail.com>
*/
class DebugDataHolder
{
private array $data = [];
public function addQuery(string $connectionName, Query $query): void
{
$this->data[$connectionName][] = [
'sql' => $query->getSql(),
'params' => $query->getParams(),
'types' => $query->getTypes(),
'executionMS' => $query->getDuration(...), // stop() may not be called at this point
];
}
public function getData(): array
{
foreach ($this->data as $connectionName => $dataForConn) {
foreach ($dataForConn as $idx => $data) {
if (\is_callable($data['executionMS'])) {
$this->data[$connectionName][$idx]['executionMS'] = $data['executionMS']();
}
}
}
return $this->data;
}
public function reset(): void
{
$this->data = [];
}
}

View File

@@ -0,0 +1,55 @@
<?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\Middleware\Debug;
use Doctrine\DBAL\Driver as DriverInterface;
use Doctrine\DBAL\Driver\Connection as ConnectionInterface;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
use Symfony\Component\Stopwatch\Stopwatch;
/**
* @author Laurent VOULLEMIER <laurent.voullemier@gmail.com>
*
* @internal
*/
final class Driver extends AbstractDriverMiddleware
{
public function __construct(
DriverInterface $driver,
private readonly DebugDataHolder $debugDataHolder,
private readonly ?Stopwatch $stopwatch,
private readonly string $connectionName,
) {
parent::__construct($driver);
}
public function connect(array $params): ConnectionInterface
{
$connection = parent::connect($params);
if ('void' !== (string) (new \ReflectionMethod(ConnectionInterface::class, 'commit'))->getReturnType()) {
return new DBAL3\Connection(
$connection,
$this->debugDataHolder,
$this->stopwatch,
$this->connectionName
);
}
return new Connection(
$connection,
$this->debugDataHolder,
$this->stopwatch,
$this->connectionName
);
}
}

View File

@@ -0,0 +1,36 @@
<?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\Middleware\Debug;
use Doctrine\DBAL\Driver as DriverInterface;
use Doctrine\DBAL\Driver\Middleware as MiddlewareInterface;
use Symfony\Component\Stopwatch\Stopwatch;
/**
* Middleware to collect debug data.
*
* @author Laurent VOULLEMIER <laurent.voullemier@gmail.com>
*/
final class Middleware implements MiddlewareInterface
{
public function __construct(
private readonly DebugDataHolder $debugDataHolder,
private readonly ?Stopwatch $stopwatch,
private readonly string $connectionName = 'default',
) {
}
public function wrap(DriverInterface $driver): DriverInterface
{
return new Driver($driver, $this->debugDataHolder, $this->stopwatch, $this->connectionName);
}
}

View File

@@ -0,0 +1,113 @@
<?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\Middleware\Debug;
use Doctrine\DBAL\ParameterType;
/**
* @author Laurent VOULLEMIER <laurent.voullemier@gmail.com>
*
* @internal
*/
class Query
{
private array $params = [];
/** @var array<ParameterType|int> */
private array $types = [];
private ?float $start = null;
private ?float $duration = null;
public function __construct(
private readonly string $sql,
) {
}
public function start(): void
{
$this->start = microtime(true);
}
public function stop(): void
{
if (null !== $this->start) {
$this->duration = microtime(true) - $this->start;
}
}
public function setParam(string|int $param, mixed &$variable, ParameterType|int $type): void
{
// Numeric indexes start at 0 in profiler
$idx = \is_int($param) ? $param - 1 : $param;
$this->params[$idx] = &$variable;
$this->types[$idx] = $type;
}
public function setValue(string|int $param, mixed $value, ParameterType|int $type): void
{
// Numeric indexes start at 0 in profiler
$idx = \is_int($param) ? $param - 1 : $param;
$this->params[$idx] = $value;
$this->types[$idx] = $type;
}
/**
* @param array<string|int, string|int|float> $values
*/
public function setValues(array $values): void
{
foreach ($values as $param => $value) {
$this->setValue($param, $value, ParameterType::STRING);
}
}
public function getSql(): string
{
return $this->sql;
}
/**
* @return array<int, string|int|float>
*/
public function getParams(): array
{
return $this->params;
}
/**
* @return array<int, int|ParameterType>
*/
public function getTypes(): array
{
return $this->types;
}
/**
* Query duration in seconds.
*/
public function getDuration(): ?float
{
return $this->duration;
}
public function __clone()
{
$copy = [];
foreach ($this->params as $param => $valueOrVariable) {
$copy[$param] = $valueOrVariable;
}
$this->params = $copy;
}
}

View File

@@ -0,0 +1,64 @@
<?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\Middleware\Debug;
use Doctrine\DBAL\Driver\Middleware\AbstractStatementMiddleware;
use Doctrine\DBAL\Driver\Result as ResultInterface;
use Doctrine\DBAL\Driver\Statement as StatementInterface;
use Doctrine\DBAL\ParameterType;
use Symfony\Component\Stopwatch\Stopwatch;
/**
* @author Laurent VOULLEMIER <laurent.voullemier@gmail.com>
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class Statement extends AbstractStatementMiddleware
{
private Query $query;
public function __construct(
StatementInterface $statement,
private readonly DebugDataHolder $debugDataHolder,
private readonly string $connectionName,
string $sql,
private readonly ?Stopwatch $stopwatch = null,
) {
parent::__construct($statement);
$this->query = new Query($sql);
}
public function bindValue(int|string $param, mixed $value, ParameterType $type): void
{
$this->query->setValue($param, $value, $type);
parent::bindValue($param, $value, $type);
}
public function execute(): ResultInterface
{
// clone to prevent variables by reference to change
$this->debugDataHolder->addQuery($this->connectionName, $query = clone $this->query);
$this->stopwatch?->start('doctrine', 'doctrine');
$query->start();
try {
return parent::execute();
} finally {
$query->stop();
$this->stopwatch?->stop('doctrine');
}
}
}

View File

@@ -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\Bridge\Doctrine\Middleware\IdleConnection;
use Doctrine\DBAL\Driver as DriverInterface;
use Doctrine\DBAL\Driver\Connection as ConnectionInterface;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
final class Driver extends AbstractDriverMiddleware
{
/**
* @param \ArrayObject<string, int> $connectionExpiries
*/
public function __construct(
DriverInterface $driver,
private \ArrayObject $connectionExpiries,
private readonly int $ttl,
private readonly string $connectionName,
) {
parent::__construct($driver);
}
public function connect(array $params): ConnectionInterface
{
$timestamp = time();
$connection = parent::connect($params);
$this->connectionExpiries[$this->connectionName] = $timestamp + $this->ttl;
return $connection;
}
}

View File

@@ -0,0 +1,59 @@
<?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\Middleware\IdleConnection;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
final class Listener implements EventSubscriberInterface
{
/**
* @param \ArrayObject<string, int> $connectionExpiries
*/
public function __construct(
private readonly \ArrayObject $connectionExpiries,
private ContainerInterface $container,
) {
}
public function onKernelRequest(RequestEvent $event): void
{
if (HttpKernelInterface::MAIN_REQUEST !== $event->getRequestType()) {
return;
}
$timestamp = time();
foreach ($this->connectionExpiries as $name => $expiry) {
if ($timestamp >= $expiry) {
// unset before so that we won't retry in case of any failure
$this->connectionExpiries->offsetUnset($name);
try {
$connection = $this->container->get("doctrine.dbal.{$name}_connection");
$connection->close();
} catch (\Exception) {
// ignore exceptions to remain fail-safe
}
}
}
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 192], // before session listeners since they could use the DB
];
}
}

View File

@@ -0,0 +1,439 @@
<?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\PropertyInfo;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\BigIntType;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\AssociationMapping;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\EmbeddedClassMapping;
use Doctrine\ORM\Mapping\FieldMapping;
use Doctrine\ORM\Mapping\JoinColumnMapping;
use Doctrine\ORM\Mapping\MappingException as OrmMappingException;
use Doctrine\Persistence\Mapping\MappingException;
use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyListExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\PropertyInfo\Type as LegacyType;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\TypeIdentifier;
/**
* Extracts data using Doctrine ORM and ODM metadata.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class DoctrineExtractor implements PropertyListExtractorInterface, PropertyTypeExtractorInterface, PropertyAccessExtractorInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {
}
public function getProperties(string $class, array $context = []): ?array
{
if (null === $metadata = $this->getMetadata($class)) {
return null;
}
$properties = array_merge($metadata->getFieldNames(), $metadata->getAssociationNames());
if ($metadata instanceof ClassMetadata && $metadata->embeddedClasses) {
$properties = array_filter($properties, fn ($property) => !str_contains($property, '.'));
$properties = array_merge($properties, array_keys($metadata->embeddedClasses));
}
return $properties;
}
public function getType(string $class, string $property, array $context = []): ?Type
{
if (null === $metadata = $this->getMetadata($class)) {
return null;
}
if ($metadata->hasAssociation($property)) {
$class = $metadata->getAssociationTargetClass($property);
if ($metadata->isSingleValuedAssociation($property)) {
if ($metadata instanceof ClassMetadata) {
$associationMapping = $metadata->getAssociationMapping($property);
$nullable = $this->isAssociationNullable($associationMapping);
} else {
$nullable = false;
}
return $nullable ? Type::nullable(Type::object($class)) : Type::object($class);
}
$collectionKeyType = TypeIdentifier::INT;
if ($metadata instanceof ClassMetadata) {
$associationMapping = $metadata->getAssociationMapping($property);
if (self::getMappingValue($associationMapping, 'indexBy')) {
$subMetadata = $this->entityManager->getClassMetadata(self::getMappingValue($associationMapping, 'targetEntity'));
// Check if indexBy value is a property
$fieldName = self::getMappingValue($associationMapping, 'indexBy');
if (null === ($typeOfField = $subMetadata->getTypeOfField($fieldName))) {
$fieldName = $subMetadata->getFieldForColumn(self::getMappingValue($associationMapping, 'indexBy'));
// Not a property, maybe a column name?
if (null === ($typeOfField = $subMetadata->getTypeOfField($fieldName))) {
// Maybe the column name is the association join column?
$associationMapping = $subMetadata->getAssociationMapping($fieldName);
$indexProperty = $subMetadata->getSingleAssociationReferencedJoinColumnName($fieldName);
$subMetadata = $this->entityManager->getClassMetadata(self::getMappingValue($associationMapping, 'targetEntity'));
// Not a property, maybe a column name?
if (null === ($typeOfField = $subMetadata->getTypeOfField($indexProperty))) {
$fieldName = $subMetadata->getFieldForColumn($indexProperty);
$typeOfField = $subMetadata->getTypeOfField($fieldName);
}
}
}
if (!$collectionKeyType = $this->getTypeIdentifier($typeOfField)) {
return null;
}
}
}
return Type::collection(Type::object(Collection::class), Type::object($class), Type::builtin($collectionKeyType));
}
if ($metadata instanceof ClassMetadata && isset($metadata->embeddedClasses[$property])) {
return Type::object(self::getMappingValue($metadata->embeddedClasses[$property], 'class'));
}
if (!$metadata->hasField($property)) {
return null;
}
$typeOfField = $metadata->getTypeOfField($property);
if (!$typeIdentifier = $this->getTypeIdentifier($typeOfField)) {
return null;
}
$nullable = $metadata instanceof ClassMetadata && $metadata->isNullable($property);
// DBAL 4 has a special fallback strategy for BINGINT (int -> string)
if (Types::BIGINT === $typeOfField && !method_exists(BigIntType::class, 'getName')) {
return $nullable ? Type::nullable(Type::union(Type::int(), Type::string())) : Type::union(Type::int(), Type::string());
}
$enumType = null;
if (null !== $enumClass = self::getMappingValue($metadata->getFieldMapping($property), 'enumType') ?? null) {
$enumType = $nullable ? Type::nullable(Type::enum($enumClass)) : Type::enum($enumClass);
}
$builtinType = $nullable ? Type::nullable(Type::builtin($typeIdentifier)) : Type::builtin($typeIdentifier);
return match ($typeIdentifier) {
TypeIdentifier::OBJECT => match ($typeOfField) {
Types::DATE_MUTABLE, Types::DATETIME_MUTABLE, Types::DATETIMETZ_MUTABLE, 'vardatetime', Types::TIME_MUTABLE => $nullable ? Type::nullable(Type::object(\DateTime::class)) : Type::object(\DateTime::class),
Types::DATE_IMMUTABLE, Types::DATETIME_IMMUTABLE, Types::DATETIMETZ_IMMUTABLE, Types::TIME_IMMUTABLE => $nullable ? Type::nullable(Type::object(\DateTimeImmutable::class)) : Type::object(\DateTimeImmutable::class),
Types::DATEINTERVAL => $nullable ? Type::nullable(Type::object(\DateInterval::class)) : Type::object(\DateInterval::class),
default => $builtinType,
},
TypeIdentifier::ARRAY => match ($typeOfField) {
'array', 'json_array' => $enumType ? null : ($nullable ? Type::nullable(Type::array()) : Type::array()),
Types::SIMPLE_ARRAY => $nullable ? Type::nullable(Type::list($enumType ?? Type::string())) : Type::list($enumType ?? Type::string()),
default => $builtinType,
},
TypeIdentifier::INT, TypeIdentifier::STRING => $enumType ? $enumType : $builtinType,
default => $builtinType,
};
}
/**
* @deprecated since Symfony 7.3, use "getType" instead
*/
public function getTypes(string $class, string $property, array $context = []): ?array
{
trigger_deprecation('symfony/property-info', '7.3', 'The "%s()" method is deprecated, use "%s::getType()" instead.', __METHOD__, self::class);
if (null === $metadata = $this->getMetadata($class)) {
return null;
}
if ($metadata->hasAssociation($property)) {
$class = $metadata->getAssociationTargetClass($property);
if ($metadata->isSingleValuedAssociation($property)) {
if ($metadata instanceof ClassMetadata) {
$associationMapping = $metadata->getAssociationMapping($property);
$nullable = $this->isAssociationNullable($associationMapping);
} else {
$nullable = false;
}
return [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, $nullable, $class)];
}
$collectionKeyType = LegacyType::BUILTIN_TYPE_INT;
if ($metadata instanceof ClassMetadata) {
$associationMapping = $metadata->getAssociationMapping($property);
if (self::getMappingValue($associationMapping, 'indexBy')) {
$subMetadata = $this->entityManager->getClassMetadata(self::getMappingValue($associationMapping, 'targetEntity'));
// Check if indexBy value is a property
$fieldName = self::getMappingValue($associationMapping, 'indexBy');
if (null === ($typeOfField = $subMetadata->getTypeOfField($fieldName))) {
$fieldName = $subMetadata->getFieldForColumn(self::getMappingValue($associationMapping, 'indexBy'));
// Not a property, maybe a column name?
if (null === ($typeOfField = $subMetadata->getTypeOfField($fieldName))) {
// Maybe the column name is the association join column?
$associationMapping = $subMetadata->getAssociationMapping($fieldName);
$indexProperty = $subMetadata->getSingleAssociationReferencedJoinColumnName($fieldName);
$subMetadata = $this->entityManager->getClassMetadata(self::getMappingValue($associationMapping, 'targetEntity'));
// Not a property, maybe a column name?
if (null === ($typeOfField = $subMetadata->getTypeOfField($indexProperty))) {
$fieldName = $subMetadata->getFieldForColumn($indexProperty);
$typeOfField = $subMetadata->getTypeOfField($fieldName);
}
}
}
if (!$collectionKeyType = $this->getTypeIdentifierLegacy($typeOfField)) {
return null;
}
}
}
return [new LegacyType(
LegacyType::BUILTIN_TYPE_OBJECT,
false,
Collection::class,
true,
new LegacyType($collectionKeyType),
new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, $class)
)];
}
if ($metadata instanceof ClassMetadata && isset($metadata->embeddedClasses[$property])) {
return [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, self::getMappingValue($metadata->embeddedClasses[$property], 'class'))];
}
if ($metadata->hasField($property)) {
$typeOfField = $metadata->getTypeOfField($property);
if (!$builtinType = $this->getTypeIdentifierLegacy($typeOfField)) {
return null;
}
$nullable = $metadata instanceof ClassMetadata && $metadata->isNullable($property);
// DBAL 4 has a special fallback strategy for BINGINT (int -> string)
if (Types::BIGINT === $typeOfField && !method_exists(BigIntType::class, 'getName')) {
return [
new LegacyType(LegacyType::BUILTIN_TYPE_INT, $nullable),
new LegacyType(LegacyType::BUILTIN_TYPE_STRING, $nullable),
];
}
$enumType = null;
if (null !== $enumClass = self::getMappingValue($metadata->getFieldMapping($property), 'enumType') ?? null) {
$enumType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, $nullable, $enumClass);
}
switch ($builtinType) {
case LegacyType::BUILTIN_TYPE_OBJECT:
switch ($typeOfField) {
case Types::DATE_MUTABLE:
case Types::DATETIME_MUTABLE:
case Types::DATETIMETZ_MUTABLE:
case 'vardatetime':
case Types::TIME_MUTABLE:
return [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, $nullable, 'DateTime')];
case Types::DATE_IMMUTABLE:
case Types::DATETIME_IMMUTABLE:
case Types::DATETIMETZ_IMMUTABLE:
case Types::TIME_IMMUTABLE:
return [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, $nullable, 'DateTimeImmutable')];
case Types::DATEINTERVAL:
return [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, $nullable, 'DateInterval')];
}
break;
case LegacyType::BUILTIN_TYPE_ARRAY:
switch ($typeOfField) {
case 'array': // DBAL < 4
case 'json_array': // DBAL < 3
// return null if $enumType is set, because we can't determine if collectionKeyType is string or int
if ($enumType) {
return null;
}
return [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, $nullable, null, true)];
case Types::SIMPLE_ARRAY:
return [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, $nullable, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $enumType ?? new LegacyType(LegacyType::BUILTIN_TYPE_STRING))];
}
break;
case LegacyType::BUILTIN_TYPE_INT:
case LegacyType::BUILTIN_TYPE_STRING:
if ($enumType) {
return [$enumType];
}
break;
}
return [new LegacyType($builtinType, $nullable)];
}
return null;
}
public function isReadable(string $class, string $property, array $context = []): ?bool
{
return null;
}
public function isWritable(string $class, string $property, array $context = []): ?bool
{
if (
null === ($metadata = $this->getMetadata($class))
|| ClassMetadata::GENERATOR_TYPE_NONE === $metadata->generatorType
|| !\in_array($property, $metadata->getIdentifierFieldNames(), true)
) {
return null;
}
return false;
}
private function getMetadata(string $class): ?ClassMetadata
{
try {
return $this->entityManager->getClassMetadata($class);
} catch (MappingException|OrmMappingException) {
return null;
}
}
/**
* Determines whether an association is nullable.
*
* @param array<string, mixed>|AssociationMapping $associationMapping
*
* @see https://github.com/doctrine/doctrine2/blob/v2.5.4/lib/Doctrine/ORM/Tools/EntityGenerator.php#L1221-L1246
*/
private function isAssociationNullable(array|AssociationMapping $associationMapping): bool
{
if (self::getMappingValue($associationMapping, 'id')) {
return false;
}
if (!self::getMappingValue($associationMapping, 'joinColumns')) {
return true;
}
$joinColumns = self::getMappingValue($associationMapping, 'joinColumns');
foreach ($joinColumns as $joinColumn) {
if (false === self::getMappingValue($joinColumn, 'nullable')) {
return false;
}
}
return true;
}
/**
* Gets the corresponding built-in PHP type.
*/
private function getTypeIdentifier(string $doctrineType): ?TypeIdentifier
{
return match ($doctrineType) {
Types::SMALLINT,
Types::INTEGER => TypeIdentifier::INT,
Types::FLOAT => TypeIdentifier::FLOAT,
Types::BIGINT,
Types::STRING,
Types::TEXT,
Types::GUID,
Types::DECIMAL => TypeIdentifier::STRING,
Types::BOOLEAN => TypeIdentifier::BOOL,
Types::BLOB,
Types::BINARY => TypeIdentifier::RESOURCE,
'object', // DBAL < 4
Types::DATE_MUTABLE,
Types::DATETIME_MUTABLE,
Types::DATETIMETZ_MUTABLE,
'vardatetime',
Types::TIME_MUTABLE,
Types::DATE_IMMUTABLE,
Types::DATETIME_IMMUTABLE,
Types::DATETIMETZ_IMMUTABLE,
Types::TIME_IMMUTABLE,
Types::DATEINTERVAL => TypeIdentifier::OBJECT,
'array', // DBAL < 4
'json_array', // DBAL < 3
Types::SIMPLE_ARRAY => TypeIdentifier::ARRAY,
default => null,
};
}
private function getTypeIdentifierLegacy(string $doctrineType): ?string
{
return match ($doctrineType) {
Types::SMALLINT,
Types::INTEGER => LegacyType::BUILTIN_TYPE_INT,
Types::FLOAT => LegacyType::BUILTIN_TYPE_FLOAT,
Types::BIGINT,
Types::STRING,
Types::TEXT,
Types::GUID,
Types::DECIMAL => LegacyType::BUILTIN_TYPE_STRING,
Types::BOOLEAN => LegacyType::BUILTIN_TYPE_BOOL,
Types::BLOB,
Types::BINARY => LegacyType::BUILTIN_TYPE_RESOURCE,
'object', // DBAL < 4
Types::DATE_MUTABLE,
Types::DATETIME_MUTABLE,
Types::DATETIMETZ_MUTABLE,
'vardatetime',
Types::TIME_MUTABLE,
Types::DATE_IMMUTABLE,
Types::DATETIME_IMMUTABLE,
Types::DATETIMETZ_IMMUTABLE,
Types::TIME_IMMUTABLE,
Types::DATEINTERVAL => LegacyType::BUILTIN_TYPE_OBJECT,
'array', // DBAL < 4
'json_array', // DBAL < 3
Types::SIMPLE_ARRAY => LegacyType::BUILTIN_TYPE_ARRAY,
default => null,
};
}
private static function getMappingValue(array|AssociationMapping|EmbeddedClassMapping|FieldMapping|JoinColumnMapping $mapping, string $key): mixed
{
if ($mapping instanceof AssociationMapping || $mapping instanceof EmbeddedClassMapping || $mapping instanceof FieldMapping || $mapping instanceof JoinColumnMapping) {
return $mapping->$key ?? null;
}
return $mapping[$key] ?? null;
}
}

View File

@@ -0,0 +1,13 @@
Doctrine Bridge
===============
The Doctrine bridge provides integration for
[Doctrine](http://www.doctrine-project.org/) with various Symfony components.
Resources
---------
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)

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\SchemaListener;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\ConnectionException;
use Doctrine\DBAL\Exception\DatabaseObjectExistsException;
use Doctrine\DBAL\Exception\DatabaseObjectNotFoundException;
use Doctrine\DBAL\Schema\Name\Identifier;
use Doctrine\DBAL\Schema\Name\UnqualifiedName;
use Doctrine\DBAL\Schema\NamedObject;
use Doctrine\DBAL\Schema\PrimaryKeyConstraint;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs;
abstract class AbstractSchemaListener
{
abstract public function postGenerateSchema(GenerateSchemaEventArgs $event): void;
protected function filterSchemaChanges(Schema $schema, Connection $connection, callable $configurator): void
{
$filter = $connection->getConfiguration()->getSchemaAssetsFilter();
if (null === $filter) {
$configurator();
return;
}
$getNames = static fn ($array) => array_map(static fn ($object) => $object instanceof NamedObject ? $object->getObjectName()->toString() : $object->getName(), $array);
$previousTableNames = $getNames($schema->getTables());
$previousSequenceNames = $getNames($schema->getSequences());
$configurator();
foreach (array_diff($getNames($schema->getTables()), $previousTableNames) as $addedTable) {
if (!$filter($addedTable)) {
$schema->dropTable($addedTable);
}
}
foreach (array_diff($getNames($schema->getSequences()), $previousSequenceNames) as $addedSequence) {
if (!$filter($addedSequence)) {
$schema->dropSequence($addedSequence);
}
}
}
/**
* @return \Closure(\Closure(string): mixed): bool
*/
protected function getIsSameDatabaseChecker(Connection $connection): \Closure
{
return static function (\Closure $exec) use ($connection): bool {
$schemaManager = method_exists($connection, 'createSchemaManager') ? $connection->createSchemaManager() : $connection->getSchemaManager();
$key = bin2hex(random_bytes(7));
$table = new Table('schema_subscriber_check_');
$table->addColumn('id', Types::INTEGER)
->setAutoincrement(true)
->setNotnull(true);
$table->addColumn('random_key', Types::STRING)
->setLength(14)
->setNotNull(true)
;
if (class_exists(PrimaryKeyConstraint::class)) {
$table->addPrimaryKeyConstraint(new PrimaryKeyConstraint(null, [new UnqualifiedName(Identifier::unquoted('id'))], true));
} else {
$table->setPrimaryKey(['id']);
}
try {
$schemaManager->createTable($table);
} catch (DatabaseObjectExistsException) {
}
$connection->executeStatement('INSERT INTO schema_subscriber_check_ (random_key) VALUES (:key)', ['key' => $key], ['key' => Types::STRING]);
try {
$exec(\sprintf('DELETE FROM schema_subscriber_check_ WHERE random_key = %s', $connection->getDatabasePlatform()->quoteStringLiteral($key)));
} catch (DatabaseObjectNotFoundException|ConnectionException|\PDOException) {
}
try {
return !$connection->executeStatement('DELETE FROM schema_subscriber_check_ WHERE random_key = :key', ['key' => $key], ['key' => Types::STRING]);
} finally {
if (!$connection->executeQuery('SELECT count(id) FROM schema_subscriber_check_')->fetchOne()) {
try {
$schemaManager->dropTable('schema_subscriber_check_');
} catch (DatabaseObjectNotFoundException) {
}
}
}
};
}
}

View File

@@ -0,0 +1,43 @@
<?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\SchemaListener;
use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs;
use Symfony\Component\Cache\Adapter\DoctrineDbalAdapter;
/**
* Automatically adds the cache table needed for the DoctrineDbalAdapter of
* the Cache component.
*/
class DoctrineDbalCacheAdapterSchemaListener extends AbstractSchemaListener
{
/**
* @param iterable<mixed, DoctrineDbalAdapter> $dbalAdapters
*/
public function __construct(
private readonly iterable $dbalAdapters,
) {
}
public function postGenerateSchema(GenerateSchemaEventArgs $event): void
{
$connection = $event->getEntityManager()->getConnection();
$schema = $event->getSchema();
foreach ($this->dbalAdapters as $dbalAdapter) {
$isSameDatabaseChecker = $this->getIsSameDatabaseChecker($connection);
$this->filterSchemaChanges($schema, $connection, static function () use ($dbalAdapter, $schema, $connection, $isSameDatabaseChecker) {
$dbalAdapter->configureSchema($schema, $connection, $isSameDatabaseChecker);
});
}
}
}

View File

@@ -0,0 +1,44 @@
<?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\SchemaListener;
use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs;
use Symfony\Component\Lock\PersistingStoreInterface;
use Symfony\Component\Lock\Store\DoctrineDbalStore;
final class LockStoreSchemaListener extends AbstractSchemaListener
{
/**
* @param iterable<mixed, PersistingStoreInterface> $stores
*/
public function __construct(
private readonly iterable $stores,
) {
}
public function postGenerateSchema(GenerateSchemaEventArgs $event): void
{
$connection = $event->getEntityManager()->getConnection();
$schema = $event->getSchema();
foreach ($this->stores as $store) {
if (!$store instanceof DoctrineDbalStore) {
continue;
}
$isSameDatabaseChecker = $this->getIsSameDatabaseChecker($connection);
$this->filterSchemaChanges($schema, $connection, static function () use ($store, $schema, $isSameDatabaseChecker) {
$store->configureSchema($schema, $isSameDatabaseChecker);
});
}
}
}

View File

@@ -0,0 +1,87 @@
<?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\SchemaListener;
use Doctrine\DBAL\Event\SchemaCreateTableEventArgs;
use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs;
use Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineTransport;
use Symfony\Component\Messenger\Transport\TransportInterface;
/**
* Automatically adds any required database tables to the Doctrine Schema.
*/
class MessengerTransportDoctrineSchemaListener extends AbstractSchemaListener
{
private const PROCESSING_TABLE_FLAG = self::class.':processing';
/**
* @param iterable<mixed, TransportInterface> $transports
*/
public function __construct(
private readonly iterable $transports,
) {
}
public function postGenerateSchema(GenerateSchemaEventArgs $event): void
{
$connection = $event->getEntityManager()->getConnection();
$schema = $event->getSchema();
foreach ($this->transports as $transport) {
if (!$transport instanceof DoctrineTransport) {
continue;
}
$isSameDatabaseChecker = $this->getIsSameDatabaseChecker($connection);
$this->filterSchemaChanges($schema, $connection, static function () use ($transport, $schema, $connection, $isSameDatabaseChecker) {
$transport->configureSchema($schema, $connection, $isSameDatabaseChecker);
});
}
}
public function onSchemaCreateTable(SchemaCreateTableEventArgs $event): void
{
$table = $event->getTable();
// if this method triggers a nested create table below, allow Doctrine to work like normal
if ($table->hasOption(self::PROCESSING_TABLE_FLAG)) {
return;
}
foreach ($this->transports as $transport) {
if (!$transport instanceof DoctrineTransport) {
continue;
}
if (!$extraSql = $transport->getExtraSetupSqlForTable($table)) {
continue;
}
// avoid this same listener from creating a loop on this table
$table->addOption(self::PROCESSING_TABLE_FLAG, true);
$createTableSql = $event->getPlatform()->getCreateTableSQL($table);
/*
* Add all the SQL needed to create the table and tell Doctrine
* to "preventDefault" so that only our SQL is used. This is
* the only way to inject some extra SQL.
*/
$event->addSql($createTableSql);
foreach ($extraSql as $sql) {
$event->addSql($sql);
}
$event->preventDefault();
return;
}
}
}

View File

@@ -0,0 +1,43 @@
<?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\SchemaListener;
use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler;
final class PdoSessionHandlerSchemaListener extends AbstractSchemaListener
{
private PdoSessionHandler $sessionHandler;
public function __construct(\SessionHandlerInterface $sessionHandler)
{
if ($sessionHandler instanceof PdoSessionHandler) {
$this->sessionHandler = $sessionHandler;
}
}
public function postGenerateSchema(GenerateSchemaEventArgs $event): void
{
if (!isset($this->sessionHandler)) {
return;
}
$connection = $event->getEntityManager()->getConnection();
$schema = $event->getSchema();
$isSameDatabaseChecker = $this->getIsSameDatabaseChecker($connection);
$sessionHandler = $this->sessionHandler;
$this->filterSchemaChanges($schema, $connection, static function () use ($sessionHandler, $schema, $isSameDatabaseChecker) {
$sessionHandler->configureSchema($schema, $isSameDatabaseChecker);
});
}
}

View File

@@ -0,0 +1,50 @@
<?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\SchemaListener;
use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs;
use Symfony\Bridge\Doctrine\Security\RememberMe\DoctrineTokenProvider;
use Symfony\Component\Security\Http\RememberMe\PersistentRememberMeHandler;
use Symfony\Component\Security\Http\RememberMe\RememberMeHandlerInterface;
/**
* Automatically adds the rememberme table needed for the {@see DoctrineTokenProvider}.
*/
class RememberMeTokenProviderDoctrineSchemaListener extends AbstractSchemaListener
{
/**
* @param iterable<mixed, RememberMeHandlerInterface> $rememberMeHandlers
*/
public function __construct(
private readonly iterable $rememberMeHandlers,
) {
}
public function postGenerateSchema(GenerateSchemaEventArgs $event): void
{
$connection = $event->getEntityManager()->getConnection();
$schema = $event->getSchema();
foreach ($this->rememberMeHandlers as $rememberMeHandler) {
if (
$rememberMeHandler instanceof PersistentRememberMeHandler
&& ($tokenProvider = $rememberMeHandler->getTokenProvider()) instanceof DoctrineTokenProvider
) {
$isSameDatabaseChecker = $this->getIsSameDatabaseChecker($connection);
$this->filterSchemaChanges($schema, $connection, static function () use ($tokenProvider, $schema, $connection, $isSameDatabaseChecker) {
/* @var DoctrineTokenProvider $tokenProvider */
$tokenProvider->configureSchema($schema, $connection, $isSameDatabaseChecker);
});
}
}
}
}

View File

@@ -0,0 +1,220 @@
<?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\Security\RememberMe;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Schema\Name\Identifier;
use Doctrine\DBAL\Schema\Name\UnqualifiedName;
use Doctrine\DBAL\Schema\PrimaryKeyConstraint;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken;
use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentTokenInterface;
use Symfony\Component\Security\Core\Authentication\RememberMe\TokenProviderInterface;
use Symfony\Component\Security\Core\Authentication\RememberMe\TokenVerifierInterface;
use Symfony\Component\Security\Core\Exception\TokenNotFoundException;
/**
* This class provides storage for the tokens that is set in "remember-me"
* cookies. This way no password secrets will be stored in the cookies on
* the client machine, and thus the security is improved.
*
* This depends only on doctrine in order to get a database connection
* and to do the conversion of the datetime column.
*
* In order to use this class, you need the following table in your database:
*
* CREATE TABLE `rememberme_token` (
* `series` char(88) UNIQUE PRIMARY KEY NOT NULL,
* `value` char(88) NOT NULL,
* `lastUsed` datetime NOT NULL,
* `class` varchar(100) DEFAULT '' NOT NULL,
* `username` varchar(200) NOT NULL
* );
*
* (the `class` column is for BC with tables created with before Symfony 8)
*/
final class DoctrineTokenProvider implements TokenProviderInterface, TokenVerifierInterface
{
public function __construct(
private readonly Connection $conn,
) {
}
public function loadTokenBySeries(string $series): PersistentTokenInterface
{
$sql = 'SELECT class, username, value, lastUsed FROM rememberme_token WHERE series=:series';
$paramValues = ['series' => $series];
$paramTypes = ['series' => ParameterType::STRING];
$stmt = $this->conn->executeQuery($sql, $paramValues, $paramTypes);
// fetching numeric because column name casing depends on platform, eg. Oracle converts all not quoted names to uppercase
$row = $stmt->fetchNumeric() ?: throw new TokenNotFoundException('No token found.');
[$class, $username, $value, $last_used] = $row;
if (method_exists(PersistentToken::class, 'getClass')) {
return new PersistentToken($class, $username, $series, $value, new \DateTimeImmutable($last_used), false);
}
return new PersistentToken($username, $series, $value, new \DateTimeImmutable($last_used));
}
public function deleteTokenBySeries(string $series): void
{
$sql = 'DELETE FROM rememberme_token WHERE series=:series';
$paramValues = ['series' => $series];
$paramTypes = ['series' => ParameterType::STRING];
$this->conn->executeStatement($sql, $paramValues, $paramTypes);
}
public function updateToken(string $series, #[\SensitiveParameter] string $tokenValue, \DateTimeInterface $lastUsed): void
{
$sql = 'UPDATE rememberme_token SET value=:value, lastUsed=:lastUsed WHERE series=:series';
$paramValues = [
'value' => $tokenValue,
'lastUsed' => \DateTimeImmutable::createFromInterface($lastUsed),
'series' => $series,
];
$paramTypes = [
'value' => ParameterType::STRING,
'lastUsed' => Types::DATETIME_IMMUTABLE,
'series' => ParameterType::STRING,
];
$updated = $this->conn->executeStatement($sql, $paramValues, $paramTypes);
if ($updated < 1) {
throw new TokenNotFoundException('No token found.');
}
}
public function createNewToken(PersistentTokenInterface $token): void
{
$sql = 'INSERT INTO rememberme_token (class, username, series, value, lastUsed) VALUES (:class, :username, :series, :value, :lastUsed)';
$paramValues = [
'class' => method_exists($token, 'getClass') ? $token->getClass(false) : '',
'username' => $token->getUserIdentifier(),
'series' => $token->getSeries(),
'value' => $token->getTokenValue(),
'lastUsed' => \DateTimeImmutable::createFromInterface($token->getLastUsed()),
];
$paramTypes = [
'class' => ParameterType::STRING,
'username' => ParameterType::STRING,
'series' => ParameterType::STRING,
'value' => ParameterType::STRING,
'lastUsed' => Types::DATETIME_IMMUTABLE,
];
$this->conn->executeStatement($sql, $paramValues, $paramTypes);
}
public function verifyToken(PersistentTokenInterface $token, #[\SensitiveParameter] string $tokenValue): bool
{
// Check if the token value matches the current persisted token
if (hash_equals($token->getTokenValue(), $tokenValue)) {
return true;
}
// Generate an alternative series id here by changing the suffix == to _
// this is needed to be able to store an older token value in the database
// which has a PRIMARY(series), and it works as long as series ids are
// generated using base64_encode(random_bytes(64)) which always outputs
// a == suffix, but if it should not work for some reason we abort
// for safety
$tmpSeries = preg_replace('{=+$}', '_', $token->getSeries());
if ($tmpSeries === $token->getSeries()) {
return false;
}
// Check if the previous token is present. If the given $tokenValue
// matches the previous token (and it is outdated by at most 60seconds)
// we also accept it as a valid value.
try {
$tmpToken = $this->loadTokenBySeries($tmpSeries);
} catch (TokenNotFoundException) {
return false;
}
if ($tmpToken->getLastUsed()->getTimestamp() + 60 < time()) {
return false;
}
return hash_equals($tmpToken->getTokenValue(), $tokenValue);
}
public function updateExistingToken(PersistentTokenInterface $token, #[\SensitiveParameter] string $tokenValue, \DateTimeInterface $lastUsed): void
{
if (!$token instanceof PersistentToken) {
return;
}
// Persist a copy of the previous token for authentication
// in verifyToken should the old token still be sent by the browser
// in a request concurrent to the one that did this token update
$tmpSeries = preg_replace('{=+$}', '_', $token->getSeries());
// if we cannot generate a unique series it is not worth trying further
if ($tmpSeries === $token->getSeries()) {
return;
}
$this->conn->beginTransaction();
try {
$this->deleteTokenBySeries($tmpSeries);
$lastUsed = \DateTime::createFromInterface($lastUsed);
if (method_exists(PersistentToken::class, 'getClass')) {
$persistentToken = new PersistentToken($token->getClass(false), $token->getUserIdentifier(), $tmpSeries, $token->getTokenValue(), $lastUsed, false);
} else {
$persistentToken = new PersistentToken($token->getUserIdentifier(), $tmpSeries, $token->getTokenValue(), $lastUsed);
}
$this->createNewToken($persistentToken);
$this->conn->commit();
} catch (\Exception $e) {
$this->conn->rollBack();
throw $e;
}
}
/**
* Adds the Table to the Schema if "remember me" uses this Connection.
*/
public function configureSchema(Schema $schema, Connection $forConnection, \Closure $isSameDatabase): void
{
if ($schema->hasTable('rememberme_token')) {
return;
}
if ($forConnection !== $this->conn && !$isSameDatabase($this->conn->executeStatement(...))) {
return;
}
$this->addTableToSchema($schema);
}
private function addTableToSchema(Schema $schema): void
{
$table = $schema->createTable('rememberme_token');
$table->addColumn('series', Types::STRING, ['length' => 88]);
$table->addColumn('value', Types::STRING, ['length' => 88]);
$table->addColumn('lastUsed', Types::DATETIME_IMMUTABLE);
$table->addColumn('class', Types::STRING, ['length' => 100, 'default' => '']);
$table->addColumn('username', Types::STRING, ['length' => 200]);
if (class_exists(PrimaryKeyConstraint::class)) {
$table->addPrimaryKeyConstraint(new PrimaryKeyConstraint(null, [new UnqualifiedName(Identifier::unquoted('series'))], true));
} else {
$table->setPrimaryKey(['series']);
}
}
}

View File

@@ -0,0 +1,167 @@
<?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\Security\User;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\Mapping\ClassMetadata;
use Doctrine\Persistence\ObjectManager;
use Doctrine\Persistence\ObjectRepository;
use Doctrine\Persistence\Proxy;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* Wrapper around a Doctrine ObjectManager.
*
* Provides provisioning for Doctrine entity users.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Johannes M. Schmitt <schmittjoh@gmail.com>
*
* @template TUser of UserInterface
*
* @template-implements UserProviderInterface<TUser>
*/
class EntityUserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
private string $class;
public function __construct(
private readonly ManagerRegistry $registry,
private readonly string $classOrAlias,
private readonly ?string $property = null,
private readonly ?string $managerName = null,
) {
}
/**
* @param ?array $attributes
*/
public function loadUserByIdentifier(string $identifier/* , ?array $attributes = null */): UserInterface
{
$repository = $this->getRepository();
if (null !== $this->property) {
$user = $repository->findOneBy([$this->property => $identifier]);
} else {
if (!$repository instanceof UserLoaderInterface) {
throw new \InvalidArgumentException(\sprintf('You must either make the "%s" entity Doctrine Repository ("%s") implement "Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface" or set the "property" option in the corresponding entity provider configuration.', $this->classOrAlias, get_debug_type($repository)));
}
if (null === $attributes = \func_num_args() > 1 ? func_get_arg(1) : null) {
$user = $repository->loadUserByIdentifier($identifier);
} else {
$user = $repository->loadUserByIdentifier($identifier, $attributes);
}
}
if (null === $user) {
$e = new UserNotFoundException(\sprintf('User "%s" not found.', $identifier));
$e->setUserIdentifier($identifier);
throw $e;
}
return $user;
}
public function refreshUser(UserInterface $user): UserInterface
{
$class = $this->getClass();
if (!$user instanceof $class) {
throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user)));
}
$repository = $this->getRepository();
if ($repository instanceof UserProviderInterface) {
$refreshedUser = $repository->refreshUser($user);
} else {
// The user must be reloaded via the primary key as all other data
// might have changed without proper persistence in the database.
// That's the case when the user has been changed by a form with
// validation errors.
if (!$id = $this->getClassMetadata()->getIdentifierValues($user)) {
throw new \InvalidArgumentException('You cannot refresh a user from the EntityUserProvider that does not contain an identifier. The user object has to be serialized with its own identifier mapped by Doctrine.');
}
$refreshedUser = $repository->find($id);
if (null === $refreshedUser) {
$e = new UserNotFoundException('User with id '.json_encode($id).' not found.');
$e->setUserIdentifier(json_encode($id));
throw $e;
}
}
if ($refreshedUser instanceof Proxy && !$refreshedUser->__isInitialized()) {
$refreshedUser->__load();
} elseif (\PHP_VERSION_ID >= 80400 && ($r = new \ReflectionClass($refreshedUser))->isUninitializedLazyObject($refreshedUser)) {
$r->initializeLazyObject($refreshedUser);
}
return $refreshedUser;
}
public function supportsClass(string $class): bool
{
return $class === $this->getClass() || is_subclass_of($class, $this->getClass());
}
/**
* @final
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
$class = $this->getClass();
if (!$user instanceof $class) {
throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user)));
}
$repository = $this->getRepository();
if ($repository instanceof PasswordUpgraderInterface) {
$repository->upgradePassword($user, $newHashedPassword);
}
}
private function getObjectManager(): ObjectManager
{
return $this->registry->getManager($this->managerName);
}
private function getRepository(): ObjectRepository
{
return $this->getObjectManager()->getRepository($this->classOrAlias);
}
private function getClass(): string
{
if (!isset($this->class)) {
$class = $this->classOrAlias;
if (str_contains($class, ':')) {
$class = $this->getClassMetadata()->getName();
}
$this->class = $class;
}
return $this->class;
}
private function getClassMetadata(): ClassMetadata
{
return $this->getObjectManager()->getClassMetadata($this->classOrAlias);
}
}

View File

@@ -0,0 +1,35 @@
<?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\Security\User;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Represents a class that loads UserInterface objects from Doctrine source for the authentication system.
*
* This interface is meant to facilitate the loading of a User from Doctrine source using a custom method.
* If you want to implement your own logic of retrieving the user from Doctrine your repository should implement this
* interface.
*
* @see UserInterface
*
* @author Michal Trojanowski <michal@kmt-studio.pl>
*/
interface UserLoaderInterface
{
/**
* Loads the user for the given user identifier (e.g. username or email).
*
* This method must return null if the user is not found.
*/
public function loadUserByIdentifier(string $identifier): ?UserInterface;
}

View File

@@ -0,0 +1,113 @@
<?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\Types;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Exception\InvalidType;
use Doctrine\DBAL\Types\Exception\ValueNotConvertible;
use Doctrine\DBAL\Types\Type;
use Symfony\Component\Uid\AbstractUid;
abstract class AbstractUidType extends Type
{
/**
* @return class-string<AbstractUid>
*/
abstract protected function getUidClass(): string;
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
if ($this->hasNativeGuidType($platform)) {
return $platform->getGuidTypeDeclarationSQL($column);
}
return $platform->getBinaryTypeDeclarationSQL([
'length' => 16,
'fixed' => true,
]);
}
/**
* @throws ConversionException
*/
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?AbstractUid
{
if ($value instanceof AbstractUid || null === $value) {
return $value;
}
if (!\is_string($value)) {
$this->throwInvalidType($value);
}
try {
return $this->getUidClass()::fromString($value);
} catch (\InvalidArgumentException $e) {
$this->throwValueNotConvertible($value, $e);
}
}
/**
* @throws ConversionException
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
$toString = $this->hasNativeGuidType($platform) ? 'toRfc4122' : 'toBinary';
if ($value instanceof AbstractUid) {
return $value->$toString();
}
if (null === $value || '' === $value) {
return null;
}
if (!\is_string($value)) {
$this->throwInvalidType($value);
}
try {
return $this->getUidClass()::fromString($value)->$toString();
} catch (\InvalidArgumentException $e) {
$this->throwValueNotConvertible($value, $e);
}
}
public function requiresSQLCommentHint(AbstractPlatform $platform): bool
{
return true;
}
private function hasNativeGuidType(AbstractPlatform $platform): bool
{
return $platform->getGuidTypeDeclarationSQL([]) !== $platform->getStringTypeDeclarationSQL(['fixed' => true, 'length' => 36]);
}
private function throwInvalidType(mixed $value): never
{
if (!class_exists(InvalidType::class)) {
throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', AbstractUid::class]);
}
throw InvalidType::new($value, $this->getName(), ['null', 'string', AbstractUid::class]);
}
private function throwValueNotConvertible(mixed $value, \Throwable $previous): never
{
if (!class_exists(ValueNotConvertible::class)) {
throw ConversionException::conversionFailed($value, $this->getName(), $previous);
}
throw ValueNotConvertible::new($value, $this->getName(), null, $previous);
}
}

View File

@@ -0,0 +1,44 @@
<?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\Types;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\DateTimeImmutableType;
use Symfony\Component\Clock\DatePoint;
final class DatePointType extends DateTimeImmutableType
{
public const NAME = 'date_point';
/**
* @param T $value
*
* @return (T is null ? null : DatePoint)
*
* @template T
*/
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?DatePoint
{
if (null === $value || $value instanceof DatePoint) {
return $value;
}
$value = parent::convertToPHPValue($value, $platform);
return DatePoint::createFromInterface($value);
}
public function getName(): string
{
return self::NAME;
}
}

View File

@@ -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\Bridge\Doctrine\Types;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\DateImmutableType;
use Symfony\Component\Clock\DatePoint;
final class DayPointType extends DateImmutableType
{
public const NAME = 'day_point';
/**
* @return ($value is null ? null : DatePoint)
*/
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?DatePoint
{
if (null === $value || $value instanceof DatePoint) {
return $value;
}
$value = parent::convertToPHPValue($value, $platform);
return DatePoint::createFromInterface($value);
}
public function getName(): string
{
return self::NAME;
}
}

View File

@@ -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\Bridge\Doctrine\Types;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\TimeImmutableType;
use Symfony\Component\Clock\DatePoint;
final class TimePointType extends TimeImmutableType
{
public const NAME = 'time_point';
/**
* @return ($value is null ? null : DatePoint)
*/
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?DatePoint
{
if (null === $value || $value instanceof DatePoint) {
return $value;
}
$value = parent::convertToPHPValue($value, $platform);
return DatePoint::createFromInterface($value);
}
public function getName(): string
{
return self::NAME;
}
}

View File

@@ -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\Bridge\Doctrine\Types;
use Symfony\Component\Uid\Ulid;
final class UlidType extends AbstractUidType
{
public const NAME = 'ulid';
public function getName(): string
{
return self::NAME;
}
protected function getUidClass(): string
{
return Ulid::class;
}
}

View File

@@ -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\Bridge\Doctrine\Types;
use Symfony\Component\Uid\Uuid;
final class UuidType extends AbstractUidType
{
public const NAME = 'uuid';
public function getName(): string
{
return self::NAME;
}
protected function getUidClass(): string
{
return Uuid::class;
}
}

View File

@@ -0,0 +1,130 @@
<?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\Validator\Constraints;
use Symfony\Component\Validator\Attribute\HasNamedArguments;
use Symfony\Component\Validator\Constraint;
/**
* Constraint for the Unique Entity validator.
*
* @author Benjamin Eberlei <kontakt@beberlei.de>
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
class UniqueEntity extends Constraint
{
public const NOT_UNIQUE_ERROR = '23bd9dbf-6b9b-41cd-a99e-4844bcf3077f';
protected const ERROR_NAMES = [
self::NOT_UNIQUE_ERROR => 'NOT_UNIQUE_ERROR',
];
public string $message = 'This value is already used.';
public string $service = 'doctrine.orm.validator.unique';
public ?string $em = null;
public ?string $entityClass = null;
public string $repositoryMethod = 'findBy';
public array|string $fields = [];
public ?string $errorPath = null;
public bool|array|string $ignoreNull = true;
public array $identifierFieldNames = [];
/**
* @param array|string $fields The combination of fields that must contain unique values or a set of options
* @param bool|string[]|string $ignoreNull The combination of fields that ignore null values
* @param string|null $em The entity manager used to query for uniqueness instead of the manager of this class
* @param string|null $entityClass The entity class to enforce uniqueness on instead of the current class
* @param string|null $repositoryMethod The repository method to check uniqueness instead of findBy. The method will receive as its argument
* a fieldName => value associative array according to the fields option configuration
* @param string|null $errorPath Bind the constraint violation to this field instead of the first one in the fields option configuration
*/
#[HasNamedArguments]
public function __construct(
array|string $fields,
?string $message = null,
?string $service = null,
?string $em = null,
?string $entityClass = null,
?string $repositoryMethod = null,
?string $errorPath = null,
bool|string|array|null $ignoreNull = null,
?array $identifierFieldNames = null,
?array $groups = null,
$payload = null,
?array $options = null,
) {
if (\is_array($fields) && \is_string(key($fields)) && [] === array_diff(array_keys($fields), array_merge(array_keys(get_class_vars(static::class)), ['value']))) {
trigger_deprecation('symfony/validator', '7.3', 'Passing an array of options to configure the "%s" constraint is deprecated, use named arguments instead.', static::class);
$options = array_merge($fields, $options ?? []);
$fields = null;
} else {
if (\is_array($options)) {
trigger_deprecation('symfony/validator', '7.3', 'Passing an array of options to configure the "%s" constraint is deprecated, use named arguments instead.', static::class);
$options['fields'] = $fields;
$fields = null;
} else {
$options = null;
}
}
parent::__construct($options, $groups, $payload);
$this->fields = $fields ?? $this->fields;
$this->message = $message ?? $this->message;
$this->service = $service ?? $this->service;
$this->em = $em ?? $this->em;
$this->entityClass = $entityClass ?? $this->entityClass;
$this->repositoryMethod = $repositoryMethod ?? $this->repositoryMethod;
$this->errorPath = $errorPath ?? $this->errorPath;
$this->ignoreNull = $ignoreNull ?? $this->ignoreNull;
$this->identifierFieldNames = $identifierFieldNames ?? $this->identifierFieldNames;
}
/**
* @deprecated since Symfony 7.4
*/
public function getRequiredOptions(): array
{
if (0 === \func_num_args() || func_get_arg(0)) {
trigger_deprecation('symfony/doctrine-bridge', '7.4', 'The %s() method is deprecated.', __METHOD__);
}
return ['fields'];
}
/**
* The validator must be defined as a service with this name.
*/
public function validatedBy(): string
{
return $this->service;
}
public function getTargets(): string|array
{
return self::CLASS_CONSTRAINT;
}
/**
* @deprecated since Symfony 7.4
*/
public function getDefaultOption(): ?string
{
if (0 === \func_num_args() || func_get_arg(0)) {
trigger_deprecation('symfony/doctrine-bridge', '7.4', 'The %s() method is deprecated.', __METHOD__);
}
return 'fields';
}
}

View File

@@ -0,0 +1,314 @@
<?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\Validator\Constraints;
use Doctrine\ORM\Mapping\MappingException as ORMMappingException;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\Mapping\ClassMetadata;
use Doctrine\Persistence\Mapping\MappingException as PersistenceMappingException;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
/**
* Unique Entity Validator checks if one or a set of fields contain unique values.
*
* @author Benjamin Eberlei <kontakt@beberlei.de>
*/
class UniqueEntityValidator extends ConstraintValidator
{
public function __construct(
private readonly ManagerRegistry $registry,
) {
}
/**
* @throws UnexpectedTypeException
* @throws ConstraintDefinitionException
*/
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof UniqueEntity) {
throw new UnexpectedTypeException($constraint, UniqueEntity::class);
}
if (!\is_array($constraint->fields) && !\is_string($constraint->fields)) {
throw new UnexpectedTypeException($constraint->fields, 'array');
}
if (null !== $constraint->errorPath && !\is_string($constraint->errorPath)) {
throw new UnexpectedTypeException($constraint->errorPath, 'string or null');
}
$fields = (array) $constraint->fields;
if (0 === \count($fields)) {
throw new ConstraintDefinitionException('At least one field has to be specified.');
}
if (null === $value) {
return;
}
if (!\is_object($value)) {
throw new UnexpectedValueException($value, 'object');
}
$entityClass = $constraint->entityClass ?? $value::class;
if ($constraint->em) {
try {
$em = $this->registry->getManager($constraint->em);
} catch (\InvalidArgumentException $e) {
throw new ConstraintDefinitionException(\sprintf('Object manager "%s" does not exist.', $constraint->em), 0, $e);
}
} else {
$em = $this->registry->getManagerForClass($entityClass);
if (!$em) {
throw new ConstraintDefinitionException(\sprintf('Unable to find the object manager associated with an entity of class "%s".', $entityClass));
}
}
try {
$em->getRepository($value::class);
$isValueEntity = true;
} catch (ORMMappingException|PersistenceMappingException) {
$isValueEntity = false;
}
$class = $em->getClassMetadata($entityClass);
$criteria = [];
$hasIgnorableNullValue = false;
$fieldValues = $this->getFieldValues($value, $class, $fields, $isValueEntity);
foreach ($fieldValues as $fieldName => $fieldValue) {
if (null === $fieldValue && $this->ignoreNullForField($constraint, $fieldName)) {
$hasIgnorableNullValue = true;
continue;
}
$criteria[$fieldName] = $fieldValue;
if (\is_object($criteria[$fieldName]) && $class->hasAssociation($fieldName)) {
/* Ensure the Proxy is initialized before using reflection to
* read its identifiers. This is necessary because the wrapped
* getter methods in the Proxy are being bypassed.
*/
$em->initializeObject($criteria[$fieldName]);
}
}
// validation doesn't fail if one of the fields is null and if null values should be ignored
if ($hasIgnorableNullValue) {
return;
}
// skip validation if there are no criteria (this can happen when the
// "ignoreNull" option is enabled and fields to be checked are null
if (!$criteria) {
return;
}
if (null !== $constraint->entityClass) {
/* Retrieve repository from given entity name.
* We ensure the retrieved repository can handle the entity
* by checking the entity is the same, or subclass of the supported entity.
*/
$repository = $em->getRepository($constraint->entityClass);
$supportedClass = $repository->getClassName();
if ($isValueEntity && !$value instanceof $supportedClass) {
$class = $em->getClassMetadata($value::class);
throw new ConstraintDefinitionException(\sprintf('The "%s" entity repository does not support the "%s" entity. The entity should be an instance of or extend "%s".', $constraint->entityClass, $class->getName(), $supportedClass));
}
} else {
$repository = $em->getRepository($value::class);
}
$arguments = [$criteria];
/* If the default repository method is used, it is always enough to retrieve at most two entities because:
* - No entity returned, the current entity is definitely unique.
* - More than one entity returned, the current entity cannot be unique.
* - One entity returned the uniqueness depends on the current entity.
*/
if ('findBy' === $constraint->repositoryMethod) {
$arguments = [$criteria, null, 2];
}
$result = $repository->{$constraint->repositoryMethod}(...$arguments);
if ($result instanceof \IteratorAggregate) {
$result = $result->getIterator();
}
/* If the result is a MongoCursor, it must be advanced to the first
* element. Rewinding should have no ill effect if $result is another
* iterator implementation.
*/
if ($result instanceof \Iterator) {
$result->rewind();
if ($result instanceof \Countable && 1 < \count($result)) {
$result = [$result->current(), $result->current()];
} else {
$result = $result->valid() && null !== $result->current() ? [$result->current()] : [];
}
} elseif (\is_array($result)) {
reset($result);
} else {
$result = null === $result ? [] : [$result];
}
/* If no entity matched the query criteria or a single entity matched,
* which is the same as the entity being validated, the criteria is
* unique.
*/
if (!$result || (1 === \count($result) && current($result) === $value)) {
return;
}
/* If a single entity matched the query criteria, which is the same as
* the entity being updated by validated object, the criteria is unique.
*/
if (!$isValueEntity && !empty($constraint->identifierFieldNames) && 1 === \count($result)) {
$fieldValues = $this->getFieldValues($value, $class, $constraint->identifierFieldNames);
if (array_values($class->getIdentifierFieldNames()) != array_values($constraint->identifierFieldNames)) {
throw new ConstraintDefinitionException(\sprintf('The "%s" entity identifier field names should be "%s", not "%s".', $entityClass, implode(', ', $class->getIdentifierFieldNames()), implode(', ', $constraint->identifierFieldNames)));
}
$entityMatched = true;
foreach ($constraint->identifierFieldNames as $identifierFieldName) {
$propertyValue = $this->getPropertyValue($entityClass, $identifierFieldName, current($result));
if ($fieldValues[$identifierFieldName] instanceof \Stringable) {
$fieldValues[$identifierFieldName] = (string) $fieldValues[$identifierFieldName];
}
if ($propertyValue instanceof \Stringable) {
$propertyValue = (string) $propertyValue;
}
if ($fieldValues[$identifierFieldName] !== $propertyValue) {
$entityMatched = false;
break;
}
}
if ($entityMatched) {
return;
}
}
$errorPath = $constraint->errorPath ?? current($fields);
$invalidValue = $criteria[$errorPath] ?? $criteria[current($fields)];
$this->context->buildViolation($constraint->message)
->atPath($errorPath)
->setParameter('{{ value }}', $this->formatWithIdentifiers($em, $class, $invalidValue))
->setInvalidValue($invalidValue)
->setCode(UniqueEntity::NOT_UNIQUE_ERROR)
->setCause($result)
->addViolation();
}
private function ignoreNullForField(UniqueEntity $constraint, string $fieldName): bool
{
if (\is_bool($constraint->ignoreNull)) {
return $constraint->ignoreNull;
}
return \in_array($fieldName, (array) $constraint->ignoreNull, true);
}
private function formatWithIdentifiers(ObjectManager $em, ClassMetadata $class, mixed $value): string
{
if (!\is_object($value) || $value instanceof \DateTimeInterface) {
return $this->formatValue($value, self::PRETTY_DATE);
}
if ($value instanceof \Stringable) {
return (string) $value;
}
if ($class->getName() !== $idClass = $value::class) {
// non-unique value might be a composite PK that consists of other entity objects
if ($em->getMetadataFactory()->hasMetadataFor($idClass)) {
$identifiers = $em->getClassMetadata($idClass)->getIdentifierValues($value);
} else {
// this case might happen if the non-unique column has a custom doctrine type and its value is an object
// in which case we cannot get any identifiers for it
$identifiers = [];
}
} else {
$identifiers = $class->getIdentifierValues($value);
}
if (!$identifiers) {
return \sprintf('object("%s")', $idClass);
}
array_walk($identifiers, function (&$id, $field) {
if (!\is_object($id) || $id instanceof \DateTimeInterface) {
$idAsString = $this->formatValue($id, self::PRETTY_DATE);
} else {
$idAsString = \sprintf('object("%s")', $id::class);
}
$id = \sprintf('%s => %s', $field, $idAsString);
});
return \sprintf('object("%s") identified by (%s)', $idClass, implode(', ', $identifiers));
}
private function getFieldValues(mixed $object, ClassMetadata $class, array $fields, bool $isValueEntity = false): array
{
if (!$isValueEntity) {
$reflectionObject = new \ReflectionObject($object);
}
$fieldValues = [];
$objectClass = $object::class;
foreach ($fields as $objectFieldName => $entityFieldName) {
if (!$class->hasField($entityFieldName) && !$class->hasAssociation($entityFieldName)) {
throw new ConstraintDefinitionException(\sprintf('The field "%s" is not mapped by Doctrine, so it cannot be validated for uniqueness.', $entityFieldName));
}
$fieldName = \is_int($objectFieldName) ? $entityFieldName : $objectFieldName;
if (!$isValueEntity && !$reflectionObject->hasProperty($fieldName)) {
throw new ConstraintDefinitionException(\sprintf('The field "%s" is not a property of class "%s".', $fieldName, $objectClass));
}
if ($isValueEntity && $object instanceof ($class->getName()) && property_exists($class, 'propertyAccessors')) {
$fieldValues[$entityFieldName] = $class->propertyAccessors[$fieldName]->getValue($object);
} elseif ($isValueEntity && $object instanceof ($class->getName())) {
$fieldValues[$entityFieldName] = $class->reflFields[$fieldName]->getValue($object);
} else {
$fieldValues[$entityFieldName] = $this->getPropertyValue($objectClass, $fieldName, $object);
}
}
return $fieldValues;
}
private function getPropertyValue(string $class, string $name, mixed $object): mixed
{
$property = new \ReflectionProperty($class, $name);
return $property->getValue($object);
}
}

View File

@@ -0,0 +1,33 @@
<?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\Validator;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Validator\ObjectInitializerInterface;
/**
* Automatically loads proxy object before validation.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class DoctrineInitializer implements ObjectInitializerInterface
{
public function __construct(
protected ManagerRegistry $registry,
) {
}
public function initialize(object $object): void
{
$this->registry->getManagerForClass($object::class)?->initializeObject($object);
}
}

View File

@@ -0,0 +1,144 @@
<?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\Validator;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata as OrmClassMetadata;
use Doctrine\ORM\Mapping\FieldMapping;
use Doctrine\ORM\Mapping\MappingException as OrmMappingException;
use Doctrine\Persistence\Mapping\MappingException;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\Valid;
use Symfony\Component\Validator\Mapping\AutoMappingStrategy;
use Symfony\Component\Validator\Mapping\ClassMetadata;
use Symfony\Component\Validator\Mapping\Loader\AutoMappingTrait;
use Symfony\Component\Validator\Mapping\Loader\LoaderInterface;
/**
* Guesses and loads the appropriate constraints using Doctrine's metadata.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
final class DoctrineLoader implements LoaderInterface
{
use AutoMappingTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ?string $classValidatorRegexp = null,
) {
}
public function loadClassMetadata(ClassMetadata $metadata): bool
{
$className = $metadata->getClassName();
try {
$doctrineMetadata = $this->entityManager->getClassMetadata($className);
} catch (MappingException|OrmMappingException) {
return false;
}
if (!$doctrineMetadata instanceof OrmClassMetadata) {
return false;
}
$loaded = false;
$enabledForClass = $this->isAutoMappingEnabledForClass($metadata, $this->classValidatorRegexp);
/* Available keys:
- type
- scale
- length
- unique
- nullable
- precision
*/
$existingUniqueFields = $this->getExistingUniqueFields($metadata);
// Type and nullable aren't handled here, use the PropertyInfo Loader instead.
foreach ($doctrineMetadata->fieldMappings as $mapping) {
$enabledForProperty = $enabledForClass;
$lengthConstraint = null;
foreach ($metadata->getPropertyMetadata(self::getFieldMappingValue($mapping, 'fieldName')) as $propertyMetadata) {
// Enabling or disabling auto-mapping explicitly always takes precedence
if (AutoMappingStrategy::DISABLED === $propertyMetadata->getAutoMappingStrategy()) {
continue 2;
}
if (AutoMappingStrategy::ENABLED === $propertyMetadata->getAutoMappingStrategy()) {
$enabledForProperty = true;
}
foreach ($propertyMetadata->getConstraints() as $constraint) {
if ($constraint instanceof Length) {
$lengthConstraint = $constraint;
}
}
}
if (!$enabledForProperty) {
continue;
}
if (true === (self::getFieldMappingValue($mapping, 'unique') ?? false) && !isset($existingUniqueFields[self::getFieldMappingValue($mapping, 'fieldName')])) {
$metadata->addConstraint(new UniqueEntity(fields: self::getFieldMappingValue($mapping, 'fieldName')));
$loaded = true;
}
if (null === (self::getFieldMappingValue($mapping, 'length') ?? null) || null !== (self::getFieldMappingValue($mapping, 'enumType') ?? null) || !\in_array(self::getFieldMappingValue($mapping, 'type'), ['string', 'text'], true)) {
continue;
}
if (null === $lengthConstraint) {
if (self::getFieldMappingValue($mapping, 'originalClass') && !str_contains(self::getFieldMappingValue($mapping, 'declaredField'), '.')) {
$metadata->addPropertyConstraint(self::getFieldMappingValue($mapping, 'declaredField'), new Valid());
$loaded = true;
} elseif (property_exists($className, self::getFieldMappingValue($mapping, 'fieldName')) && (!$doctrineMetadata->isMappedSuperclass || $metadata->getReflectionClass()->getProperty(self::getFieldMappingValue($mapping, 'fieldName'))->isPrivate())) {
$metadata->addPropertyConstraint(self::getFieldMappingValue($mapping, 'fieldName'), new Length(max: self::getFieldMappingValue($mapping, 'length')));
$loaded = true;
}
} elseif (null === $lengthConstraint->max) {
// If a Length constraint exists and no max length has been explicitly defined, set it
$lengthConstraint->max = self::getFieldMappingValue($mapping, 'length');
}
}
return $loaded;
}
private function getExistingUniqueFields(ClassMetadata $metadata): array
{
$fields = [];
foreach ($metadata->getConstraints() as $constraint) {
if (!$constraint instanceof UniqueEntity) {
continue;
}
if (\is_string($constraint->fields)) {
$fields[$constraint->fields] = true;
} elseif (\is_array($constraint->fields) && 1 === \count($constraint->fields)) {
$fields[$constraint->fields[0]] = true;
}
}
return $fields;
}
private static function getFieldMappingValue(array|FieldMapping $mapping, string $key): mixed
{
if ($mapping instanceof FieldMapping) {
return $mapping->$key ?? null;
}
return $mapping[$key] ?? null;
}
}

View File

@@ -0,0 +1,76 @@
{
"name": "symfony/doctrine-bridge",
"type": "symfony-bridge",
"description": "Provides integration for Doctrine with various Symfony components",
"keywords": [],
"homepage": "https://symfony.com",
"license": "MIT",
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"require": {
"php": ">=8.2",
"doctrine/event-manager": "^2",
"doctrine/persistence": "^3.1|^4",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.0",
"symfony/service-contracts": "^2.5|^3"
},
"require-dev": {
"symfony/cache": "^6.4|^7.0|^8.0",
"symfony/config": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/doctrine-messenger": "^6.4|^7.0|^8.0",
"symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/form": "^7.2|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/lock": "^6.4|^7.0|^8.0",
"symfony/messenger": "^6.4|^7.0|^8.0",
"symfony/property-access": "^6.4|^7.0|^8.0",
"symfony/property-info": "^6.4|^7.0|^8.0",
"symfony/security-core": "^6.4|^7.0|^8.0",
"symfony/stopwatch": "^6.4|^7.0|^8.0",
"symfony/translation": "^6.4|^7.0|^8.0",
"symfony/type-info": "^7.1.8|^8.0",
"symfony/uid": "^6.4|^7.0|^8.0",
"symfony/validator": "^7.4|^8.0",
"symfony/var-dumper": "^6.4|^7.0|^8.0",
"doctrine/collections": "^1.8|^2.0",
"doctrine/data-fixtures": "^1.1|^2",
"doctrine/dbal": "^3.6|^4",
"doctrine/orm": "^2.15|^3",
"psr/log": "^1|^2|^3"
},
"conflict": {
"doctrine/collections": "<1.8",
"doctrine/dbal": "<3.6",
"doctrine/lexer": "<1.1",
"doctrine/orm": "<2.15",
"symfony/cache": "<6.4",
"symfony/dependency-injection": "<6.4",
"symfony/form": "<6.4.6|>=7,<7.0.6",
"symfony/http-foundation": "<6.4",
"symfony/http-kernel": "<6.4",
"symfony/lock": "<6.4",
"symfony/messenger": "<6.4",
"symfony/property-info": "<6.4",
"symfony/security-bundle": "<6.4",
"symfony/security-core": "<6.4",
"symfony/validator": "<7.4"
},
"autoload": {
"psr-4": { "Symfony\\Bridge\\Doctrine\\": "" },
"exclude-from-classmap": [
"/Tests/"
]
},
"minimum-stability": "dev"
}