/
githubmirror
/
symfony
Обзор
Документация
Войти
/
githubmirror
/
symfony
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
8.2
src/Symfony/Component/ObjectMapper/ObjectMapper.php
590 строк
23 KB
Ousama Ben Younes
[ObjectMapper] Add MappingAwareTransformCallableInterface
3 часа назад
3 часа назад
2b9da3c
Код
Авторство
О чём код?
<?php /* * This file is part of the Symfony package. * * (c) Fabien Potencier <fabien@symfony.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\ObjectMapper; use Psr\Container\ContainerInterface; use Symfony\Component\ObjectMapper\Condition\ClassRuleConditionCallableInterface; use Symfony\Component\ObjectMapper\Exception\MappingException; use Symfony\Component\ObjectMapper\Exception\MappingTransformException; use Symfony\Component\ObjectMapper\Exception\NoSuchCallableException; use Symfony\Component\ObjectMapper\Exception\NoSuchPropertyException; use Symfony\Component\ObjectMapper\Metadata\Mapping; use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException as PropertyAccessorNoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\VarExporter\LazyObjectInterface; /** * Object to object mapper. * * @author Antoine Bluchet <soyuka@gmail.com> */ final class ObjectMapper implements ObjectMapperInterface, ObjectMapperAwareInterface { use ClassHierarchyTrait; /** * Tracks recursive references. */ private ?\WeakMap $objectMap = null; public function __construct( private readonly ObjectMapperMetadataFactoryInterface $metadataFactory = new ReflectionObjectMapperMetadataFactory(), private readonly ?PropertyAccessorInterface $propertyAccessor = null, private readonly ?ContainerInterface $transformCallableLocator = null, private readonly ?ContainerInterface $conditionCallableLocator = null, private ?ObjectMapperInterface $objectMapper = null, ) { } public function map(object $source, object|string|null $target = null): object { if ($this->objectMap) { return $this->doMap($source, $target, $this->objectMap); } $this->objectMap = new \WeakMap(); try { return $this->doMap($source, $target, $this->objectMap); } finally { $this->objectMap = null; } } private function doMap(object $source, object|string|null $target, \WeakMap $objectMap, bool $constructTarget = false): object { $metadata = $this->metadataFactory->create($source); $map = $this->getMapTarget($this->filterMetadataByTarget($metadata, $target), null, $source, null, null === $target); $target ??= $map?->target; $mappingToObject = \is_object($target); if (!$target) { throw new MappingException(\sprintf('Mapping target not found for source "%s".', get_debug_type($source))); } if (\is_string($target) && !class_exists($target)) { throw new MappingException(\sprintf('Mapping target class "%s" does not exist for source "%s".', $target, get_debug_type($source))); } try { $targetRefl = new \ReflectionClass($target); } catch (\ReflectionException $e) { throw new MappingException($e->getMessage(), $e->getCode(), $e); } $mappedTarget = $mappingToObject ? $target : $targetRefl->newInstanceWithoutConstructor(); if (!$metadata && $targetMetadata = $this->metadataFactory->create($mappedTarget)) { $metadata = $targetMetadata; $map = $this->getMapTarget($metadata, null, $source, null, false); } if ($map && $map->transform) { $mappedTarget = $this->applyTransforms($map, $mappedTarget, $source, null); if (!\is_object($mappedTarget)) { throw new MappingTransformException(\sprintf('Cannot map "%s" to a non-object target of type "%s".', get_debug_type($source), get_debug_type($mappedTarget))); } } if (!is_a($mappedTarget, $targetRefl->getName(), false)) { throw new MappingException(\sprintf('Expected the mapped object to be an instance of "%s" but got "%s".', $targetRefl->getName(), get_debug_type($mappedTarget))); } $objectMap[$source] = $mappedTarget; $ctorArguments = []; $targetConstructor = $targetRefl->getConstructor(); if (!$mappingToObject || $constructTarget) { foreach ($targetConstructor?->getParameters() ?? [] as $parameter) { $parameterName = $parameter->getName(); if ($targetRefl->hasProperty($parameterName)) { $property = $targetRefl->getProperty($parameterName); if ($property->isReadOnly() && $property->isInitialized($mappedTarget)) { continue; } } if ($this->isReadable($source, $parameterName)) { $ctorArguments[$parameterName] = $this->getRawValue($source, $parameterName); } else { $ctorArguments[$parameterName] = $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; } } } $readMetadataFrom = $source; $refl = $this->getSourceReflectionClass($source) ?? $targetRefl; // When source contains no metadata, we read metadata on the target instead if ($readMetadataFromTarget = $refl === $targetRefl) { $readMetadataFrom = $mappedTarget; } $mapToProperties = []; $targetName = $targetRefl->getName(); $explicitTargets = []; $implicitValues = []; foreach ($this->getAllProperties($refl) as $property) { if ($property->isStatic()) { continue; } $propertyName = $property->getName(); $mappings = $this->metadataFactory->create($readMetadataFrom, $propertyName, [ 'source' => $source::class, 'target' => $targetRefl->getName(), ]); $mappings = array_filter($mappings, static fn (Mapping $m): bool => !$m->targetClass || is_a($targetName, $m->targetClass, true)); foreach ($mappings as $mapping) { // when metadata is read from the source, $mapping->source describes the // reverse mapping and must not be resolved against $source $sourcePropertyName = $readMetadataFromTarget ? $mapping->source ?? $propertyName : $propertyName; $targetPropertyName = $mapping->target ?? $propertyName; if (false === $if = $mapping->if) { unset($ctorArguments[$targetPropertyName]); continue; } $fn = null; $isClassRule = false; if ($if) { $fn = $this->getCallable($if, $this->conditionCallableLocator, ConditionCallableInterface::class); $isClassRule = $fn instanceof ClassRuleConditionCallableInterface; if ($isClassRule && !$this->call($fn, null, $source, $mappedTarget)) { continue; } } if (!$this->isReadable($source, $sourcePropertyName) && $this->getPropertyFromHierarchy(new \ReflectionClass($source), $sourcePropertyName) ) { continue; } $value = $this->getRawValue($source, $sourcePropertyName); if ($fn && !$isClassRule && !$this->call($fn, $value, $source, $mappedTarget)) { unset($ctorArguments[$targetPropertyName]); continue; } $value = $this->getSourceValue($source, $mappedTarget, $value, $objectMap, $mapping, $targetPropertyName); $explicitTargets[$targetPropertyName] = true; $this->storeValue($targetPropertyName, $mapToProperties, $ctorArguments, $value); } if ($mappings) { continue; } if ($targetRefl->hasProperty($propertyName)) { if (!$this->isReadable($source, $propertyName)) { continue; } $sourceProperty = $this->getPropertyFromHierarchy($refl, $propertyName); if ($sourceProperty && $refl->isInstance($source) && !$sourceProperty->isInitialized($source)) { continue; } // when metadata is read from the source, the #[Map] declared on the target property // itself is never surfaced above, so honor its transform for the same-name copy $sameNameMapping = $readMetadataFromTarget ? null : $this->getSameNameTargetMapping($mappedTarget, $propertyName); $implicitValues[$propertyName] = $this->getSourceValue($source, $mappedTarget, $this->getRawValue($source, $propertyName), $objectMap, $sameNameMapping, $propertyName); continue; } if (!$this->isReadable($source, $propertyName, $refl)) { continue; } $rawValue = $this->getRawValue($source, $propertyName); if ( \is_object($rawValue) // a self-referencing relation maps to the same target by definition, merging it would overwrite the target with the related object's values && !$rawValue instanceof $source && !$objectMap->offsetExists($rawValue) && ($innerMetadata = $this->metadataFactory->create($rawValue)) && array_any($innerMetadata, static fn (Mapping $m): bool => \is_string($m->target) && is_a($targetName, $m->target, true)) ) { ($this->objectMapper ?? $this)->map($rawValue, $mappedTarget); } } foreach ($implicitValues as $propertyName => $value) { if (!isset($explicitTargets[$propertyName])) { $this->storeValue($propertyName, $mapToProperties, $ctorArguments, $value); } } if ((!$mappingToObject || $constructTarget) && !$map?->transform && $targetConstructor && ($ctorArguments || !$targetConstructor->getNumberOfRequiredParameters()) ) { try { $mappedTarget->__construct(...$ctorArguments); } catch (\ReflectionException $e) { throw new MappingException($e->getMessage(), $e->getCode(), $e); } } foreach ($mapToProperties as $property => $value) { if ($this->propertyAccessor) { if ($this->propertyAccessor->isWritable($mappedTarget, $property)) { $this->propertyAccessor->setValue($mappedTarget, $property, $value); } continue; } if (!$this->propertyIsMappable($targetRefl, $property)) { continue; } $mappedTarget->{$property} = $value; } return $mappedTarget; } private function isReadable(object $source, string $propertyName, ?\ReflectionClass $refl = null): bool { if ($this->propertyAccessor) { return $this->propertyAccessor->isReadable($source, $propertyName); } if (!property_exists($source, $propertyName)) { // only a private property declared by a parent class is invisible to property_exists(); // like any other non-public property, it can only be read through magic __get() if ($this->getPropertyFromHierarchy($refl ??= new \ReflectionClass($source), $propertyName)) { return method_exists($source, '__get'); } return isset($source->{$propertyName}); } $refl ??= new \ReflectionClass($source); if (!$refl->hasProperty($propertyName)) { // ReflectionClass doesn't see dynamic properties: property_exists() matched one, and those are always public return true; } $property = $refl->getProperty($propertyName); if (!$property->isPublic()) { // a non-public property can only be read through magic __get() return method_exists($source, '__get'); } // an uninitialized property is not readable, unless unset() re-enabled magic methods on it return $property->isInitialized($source) || isset($source->{$propertyName}); } private function getRawValue(object $source, string $propertyName): mixed { if ($this->propertyAccessor) { try { return $this->propertyAccessor->getValue($source, $propertyName); } catch (PropertyAccessorNoSuchPropertyException $e) { throw new NoSuchPropertyException($e->getMessage(), $e->getCode(), $e); } } if (!property_exists($source, $propertyName) && !isset($source->{$propertyName}) && !$this->getPropertyFromHierarchy(new \ReflectionClass($source), $propertyName) ) { throw new NoSuchPropertyException(\sprintf('The property "%s" does not exist on "%s".', $propertyName, get_debug_type($source))); } return $source->{$propertyName}; } /** * Returns the unconditional #[Map] declared on the target's own property when it describes a * same-name copy (same source and target property name), so its transform still applies even * when the iteration reads metadata from the source side. Conditional mappings are left to the * regular same-name copy, which does not evaluate conditions. Mappings carrying a target class * are synthesized for another target and must not leak their transform into this one. */ private function getSameNameTargetMapping(object $target, string $propertyName): ?Mapping { foreach ($this->metadataFactory->create($target, $propertyName) as $mapping) { if (null === $mapping->if && !$mapping->targetClass && ($mapping->target ?? $propertyName) === $propertyName && ($mapping->source ?? $propertyName) === $propertyName ) { return $mapping; } } return null; } private function getSourceValue(object $source, object $target, mixed $value, \WeakMap $objectMap, ?Mapping $mapping = null, ?string $targetPropertyName = null): mixed { if ($mapping?->transform) { $value = $this->applyTransforms($mapping, $value, $source, $target); } if ( \is_object($value) && ($innerMetadata = $this->metadataFactory->create($value)) && ($innerMetadata = $this->filterMetadataByPropertyType($innerMetadata, $target, $targetPropertyName)) && ($mapTo = $this->getMapTarget($innerMetadata, $value, $source, $target, true)) && (\is_string($mapTo->target) && class_exists($mapTo->target)) ) { $value = $this->applyTransforms($mapTo, $value, $source, $target); if ($value === $source) { $value = $target; } elseif ($objectMap->offsetExists($value)) { $value = $objectMap[$value]; } else { if ($mapTo->transform) { return $value; } $refl = new \ReflectionClass($mapTo->target); $mapper = $this->objectMapper ?? $this; return $refl->newLazyGhost(function ($target) use ($mapper, $value, $objectMap) { $previousMap = $this->objectMap; $this->objectMap = $objectMap; try { // the ghost has not run a constructor yet, unlike a caller-supplied target $objectMap[$value] = $mapper === $this ? $this->doMap($value, $target, $objectMap, true) : $mapper->map($value, $target); } finally { $this->objectMap = $previousMap; } }); } } return $value; } /** * Store the value either the constructor arguments or as a property to be mapped. * * @param array<string, mixed> $mapToProperties * @param array<string, mixed> $ctorArguments */ private function storeValue(string $propertyName, array &$mapToProperties, array &$ctorArguments, mixed $value): void { if (\array_key_exists($propertyName, $ctorArguments)) { $ctorArguments[$propertyName] = $value; return; } $mapToProperties[$propertyName] = $value; } /** * @param-immediately-invoked-callable $fn * * @param callable(): mixed $fn */ private function call(callable $fn, mixed $value, object $source, ?object $target = null): mixed { if (\is_string($fn)) { return \call_user_func($fn, $value); } return $fn($value, $source, $target); } /** * @param Mapping[] $metadata */ private function getMapTarget(array $metadata, mixed $value, object $source, ?object $target, bool $enforceUnique = false): ?Mapping { $mapTo = null; foreach ($metadata as $mapAttribute) { if (($if = $mapAttribute->if) && ($fn = $this->getCallable($if, $this->conditionCallableLocator, ConditionCallableInterface::class)) && !$this->call($fn, $value, $source, $target)) { continue; } if ($enforceUnique && null !== $mapTo) { throw new MappingException(\sprintf('Ambiguous mapping for "%s". Multiple #[Map] attributes match. Use the "if" parameter to specify conditions.', get_debug_type($value ?? $source))); } $mapTo = $mapAttribute; } return $mapTo; } /** * Narrows the class-level mappings of a source to the ones related to the target the caller asked for. * * A mapping declaring a subclass of that target is kept: its transform can still produce an * instance the caller accepts. * * @param Mapping[] $metadata * * @return Mapping[] */ private function filterMetadataByTarget(array $metadata, object|string|null $target): array { if (null === $target) { return $metadata; } $targetClass = \is_object($target) ? $target::class : $target; return array_filter($metadata, static fn (Mapping $m): bool => null === $m->target || is_a($targetClass, $m->target, true) || is_a($m->target, $targetClass, true)); } /** * Narrows the mappings of a nested object to the one matching the declared type of the property it is mapped into. * * A nested class declaring several #[Map] targets yields one Mapping per target. When the * destination property is typed, only one of them can be assigned to it, so the mapping is not ambiguous. * When none of them can be assigned to it, no mapping applies and the value is written as it is. * Mappings carrying a transform are kept: what lands in the property is the transform's return value, * not the declared target. * * @param Mapping[] $metadata * * @return Mapping[] */ private function filterMetadataByPropertyType(array $metadata, object $target, ?string $targetPropertyName): array { if (null === $targetPropertyName || !$metadata) { return $metadata; } if (!$property = $this->getPropertyFromHierarchy(new \ReflectionClass($target), $targetPropertyName)) { return $metadata; } $type = $property->getType(); if (!$type instanceof \ReflectionNamedType || $type->isBuiltin()) { return $metadata; } $propertyClass = $type->getName(); $filtered = array_values(array_filter($metadata, static fn (Mapping $m): bool => $m->transform || (\is_string($m->target) && class_exists($m->target) && is_a($m->target, $propertyClass, true)) )); return 1 < \count($filtered) ? $metadata : $filtered; } private function applyTransforms(Mapping $map, mixed $value, object $source, ?object $target): mixed { if (!$transforms = $map->transform) { return $value; } if (\is_callable($transforms)) { $transforms = [$transforms]; } elseif (!\is_array($transforms)) { $transforms = [$transforms]; } foreach ($transforms as $transform) { $fn = $this->getCallable($transform, $this->transformCallableLocator, TransformCallableInterface::class); if ($fn instanceof ObjectMapperAwareInterface) { $fn = $fn->withObjectMapper($this->objectMapper ?? $this); } $value = $fn instanceof MappingAwareTransformCallableInterface ? $fn($value, $source, $target, $map) : $this->call($fn, $value, $source, $target); } return $value; } /** * @param (string|callable(mixed $value, object $object): mixed) $fn * @param class-string|null $expectedInterface */ private function getCallable(string|callable $fn, ?ContainerInterface $locator = null, ?string $expectedInterface = null): callable { if (\is_callable($fn)) { if ($expectedInterface && \is_object($fn) && !$fn instanceof $expectedInterface) { throw new NoSuchCallableException(\sprintf('"%s" is not a valid callable. Make sure it implements "%s".', get_debug_type($fn), $expectedInterface)); } return $fn; } if ($locator?->has($fn)) { $callable = $locator->get($fn); if ($expectedInterface && !$callable instanceof $expectedInterface) { throw new NoSuchCallableException(\sprintf('"%s" is not a valid callable. Make sure it implements "%s".', $fn, $expectedInterface)); } return $callable; } throw new NoSuchCallableException(\sprintf('"%s" is not a valid callable.', $fn).($expectedInterface ? \sprintf(' If you use a class, make sure it implements "%s".', $expectedInterface) : '')); } /** * @return ?\ReflectionClass<object|T> */ private function getSourceReflectionClass(object $source): ?\ReflectionClass { $metadata = $this->metadataFactory->create($source); try { $refl = new \ReflectionClass($source); } catch (\ReflectionException $e) { throw new MappingException($e->getMessage(), $e->getCode(), $e); } if ($source instanceof LazyObjectInterface) { $source->initializeLazyObject(); } elseif ($refl->isUninitializedLazyObject($source)) { $refl->initializeLazyObject($source); } if ($metadata) { return $refl; } foreach ($this->getAllProperties($refl) as $property) { if ($this->metadataFactory->create($source, $property->getName())) { return $refl; } } return null; } private function propertyIsMappable(\ReflectionClass $targetRefl, int|string $property): bool { return $targetRefl->hasProperty($property) && $targetRefl->getProperty($property)->isPublic(); } public function withObjectMapper(ObjectMapperInterface $objectMapper): static { $clone = clone $this; $clone->objectMapper = $objectMapper; return $clone; } }