diff --git a/composer.json b/composer.json index ee17718abd750..1cc8af1144113 100644 --- a/composer.json +++ b/composer.json @@ -91,6 +91,7 @@ "symfony/mime": "self.version", "symfony/monolog-bridge": "self.version", "symfony/notifier": "self.version", + "symfony/object-mapper": "self.version", "symfony/options-resolver": "self.version", "symfony/password-hasher": "self.version", "symfony/process": "self.version", diff --git a/src/Symfony/Component/ObjectMapper/.gitattributes b/src/Symfony/Component/ObjectMapper/.gitattributes new file mode 100644 index 0000000000000..14c3c35940427 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/Symfony/Component/ObjectMapper/.github/PULL_REQUEST_TEMPLATE.md b/src/Symfony/Component/ObjectMapper/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000000..4689c4dad430e --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/symfony + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Symfony/Component/ObjectMapper/.github/workflows/check-subtree-split.yml b/src/Symfony/Component/ObjectMapper/.github/workflows/check-subtree-split.yml new file mode 100644 index 0000000000000..16be48bae3113 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/.github/workflows/check-subtree-split.yml @@ -0,0 +1,37 @@ +name: Check subtree split + +on: + pull_request_target: + +jobs: + close-pull-request: + runs-on: ubuntu-latest + + steps: + - name: Close pull request + uses: actions/github-script@v6 + with: + script: | + if (context.repo.owner === "symfony") { + github.rest.issues.createComment({ + owner: "symfony", + repo: context.repo.repo, + issue_number: context.issue.number, + body: ` + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! + ` + }); + + github.rest.pulls.update({ + owner: "symfony", + repo: context.repo.repo, + pull_number: context.issue.number, + state: "closed" + }); + } diff --git a/src/Symfony/Component/ObjectMapper/.github/workflows/close-pull-request.yml b/src/Symfony/Component/ObjectMapper/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000000000..e55b47817e69a --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Symfony/Component/ObjectMapper/.gitignore b/src/Symfony/Component/ObjectMapper/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/ObjectMapper/Attribute/Map.php b/src/Symfony/Component/ObjectMapper/Attribute/Map.php new file mode 100644 index 0000000000000..f3057bf14cd26 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Attribute/Map.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Attribute; + +/** + * Configures a class or a property to map to. + * + * @experimental + * + * @author Antoine Bluchet + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)] +readonly class Map +{ + /** + * @param string|class-string|null $source The property or the class to map from + * @param string|class-string|null $target The property or the class to map to + * @param string|bool|callable(mixed, object): bool|null $if A boolean, a service id or a callable that instructs whether to map + * @param (string|callable(mixed, object): mixed)|(string|callable(mixed, object): mixed)[]|null $transform A service id or a callable that transforms the value during mapping + */ + public function __construct( + public ?string $target = null, + public ?string $source = null, + public mixed $if = null, + public mixed $transform = null, + ) { + } +} diff --git a/src/Symfony/Component/ObjectMapper/CHANGELOG.md b/src/Symfony/Component/ObjectMapper/CHANGELOG.md new file mode 100644 index 0000000000000..0f29770616c5f --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.3 +--- + + * Add the component as experimental diff --git a/src/Symfony/Component/ObjectMapper/ConditionCallableInterface.php b/src/Symfony/Component/ObjectMapper/ConditionCallableInterface.php new file mode 100644 index 0000000000000..7cd85fd9b366f --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/ConditionCallableInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper; + +/** + * Service used by "Map::if". + * + * @template T of object + * + * @experimental + * + * {@see Symfony\Component\ObjectMapper\Attribute\Map} + */ +interface ConditionCallableInterface +{ + /** + * @param mixed $value The value being mapped + * @param T $object The object we're working on + */ + public function __invoke(mixed $value, object $object): bool; +} diff --git a/src/Symfony/Component/ObjectMapper/Exception/ExceptionInterface.php b/src/Symfony/Component/ObjectMapper/Exception/ExceptionInterface.php new file mode 100644 index 0000000000000..ce9966f06e37d --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Exception/ExceptionInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Exception; + +/** + * @experimental + * + * @author Antoine Bluchet + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/ObjectMapper/Exception/MappingException.php b/src/Symfony/Component/ObjectMapper/Exception/MappingException.php new file mode 100644 index 0000000000000..7c0885c93703c --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Exception/MappingException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Exception; + +/** + * @experimental + * + * @author Antoine Bluchet + */ +class MappingException extends RuntimeException +{ +} diff --git a/src/Symfony/Component/ObjectMapper/Exception/MappingTransformException.php b/src/Symfony/Component/ObjectMapper/Exception/MappingTransformException.php new file mode 100644 index 0000000000000..dc660d10b8269 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Exception/MappingTransformException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Exception; + +/** + * @experimental + * + * @author Antoine Bluchet + */ +final class MappingTransformException extends RuntimeException +{ +} diff --git a/src/Symfony/Component/ObjectMapper/Exception/RuntimeException.php b/src/Symfony/Component/ObjectMapper/Exception/RuntimeException.php new file mode 100644 index 0000000000000..94ca7a8428048 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Exception/RuntimeException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Exception; + +/** + * @experimental + * + * @author Antoine Bluchet + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/ObjectMapper/LICENSE b/src/Symfony/Component/ObjectMapper/LICENSE new file mode 100644 index 0000000000000..bc38d714ef697 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-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. diff --git a/src/Symfony/Component/ObjectMapper/Metadata/Mapping.php b/src/Symfony/Component/ObjectMapper/Metadata/Mapping.php new file mode 100644 index 0000000000000..455c0af79d2a7 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Metadata/Mapping.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Metadata; + +/** + * Configures a class or a property to map to. + * + * @internal + * + * @author Antoine Bluchet + */ +readonly class Mapping +{ + /** + * @param string|class-string|null $source The property or the class to map from + * @param string|class-string|null $target The property or the class to map to + * @param string|bool|callable(mixed, object): bool|null $if A boolean, Symfony service name or a callable that instructs whether to map + * @param (string|callable(mixed, object): mixed)|(string|callable(mixed, object): mixed)[]|null $transform A service id or a callable that transform the value during mapping + */ + public function __construct( + public ?string $target = null, + public ?string $source = null, + public mixed $if = null, + public mixed $transform = null, + ) { + } +} diff --git a/src/Symfony/Component/ObjectMapper/Metadata/ObjectMapperMetadataFactoryInterface.php b/src/Symfony/Component/ObjectMapper/Metadata/ObjectMapperMetadataFactoryInterface.php new file mode 100644 index 0000000000000..f78cab88bf575 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Metadata/ObjectMapperMetadataFactoryInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Metadata; + +/** + * Factory to create Mapper metadata. + * + * @experimental + * + * @author Antoine Bluchet + */ +interface ObjectMapperMetadataFactoryInterface +{ + /** + * @param array $context + * + * @return list + */ + public function create(object $object, ?string $property = null, array $context = []): array; +} diff --git a/src/Symfony/Component/ObjectMapper/Metadata/ReflectionObjectMapperMetadataFactory.php b/src/Symfony/Component/ObjectMapper/Metadata/ReflectionObjectMapperMetadataFactory.php new file mode 100644 index 0000000000000..2470e9c304346 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Metadata/ReflectionObjectMapperMetadataFactory.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Metadata; + +use Symfony\Component\ObjectMapper\Attribute\Map; +use Symfony\Component\ObjectMapper\Exception\MappingException; + +/** + * @internal + * + * @author Antoine Bluchet + */ +final class ReflectionObjectMapperMetadataFactory implements ObjectMapperMetadataFactoryInterface +{ + public function create(object $object, ?string $property = null, array $context = []): array + { + try { + $refl = new \ReflectionClass($object); + $mapTo = []; + foreach (($property ? $refl->getProperty($property) : $refl)->getAttributes(Map::class, \ReflectionAttribute::IS_INSTANCEOF) as $mapAttribute) { + $map = $mapAttribute->newInstance(); + $mapTo[] = new Mapping($map->target, $map->source, $map->if, $map->transform); + } + + return $mapTo; + } catch (\ReflectionException $e) { + throw new MappingException($e->getMessage(), $e->getCode(), $e); + } + } +} diff --git a/src/Symfony/Component/ObjectMapper/ObjectMapper.php b/src/Symfony/Component/ObjectMapper/ObjectMapper.php new file mode 100644 index 0000000000000..1f4101cead1fd --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/ObjectMapper.php @@ -0,0 +1,312 @@ + + * + * 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\Exception\MappingException; +use Symfony\Component\ObjectMapper\Exception\MappingTransformException; +use Symfony\Component\ObjectMapper\Metadata\Mapping; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; + +/** + * Object to object mapper. + * + * @experimental + * + * @author Antoine Bluchet + */ +final class ObjectMapper implements ObjectMapperInterface +{ + /** + * A SplObjectStorage that tracks recursive references. + */ + private ?\SplObjectStorage $objectMap = null; + + /** + * @param ContainerInterface $transformCallableLocator + * @param ContainerInterface $conditionCallableLocator + */ + public function __construct( + private readonly ObjectMapperMetadataFactoryInterface $metadataFactory = new ReflectionObjectMapperMetadataFactory(), + private readonly ?PropertyAccessorInterface $propertyAccessor = null, + private readonly ?ContainerInterface $transformCallableLocator = null, + private readonly ?ContainerInterface $conditionCallableLocator = null, + ) { + } + + public function map(object $source, object|string|null $target = null): object + { + $objectMapInitialized = false; + if (null === $this->objectMap) { + $this->objectMap = new \SplObjectStorage(); + $objectMapInitialized = true; + } + + $metadata = $this->metadataFactory->create($source); + $map = $this->getMapTarget($metadata, null, $source); + $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 ($map && $map->transform) { + $mappedTarget = $this->applyTransforms($map, $mappedTarget, $mappedTarget); + + 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))); + } + + $this->objectMap[$source] = $mappedTarget; + $ctorArguments = []; + $constructor = $targetRefl->getConstructor(); + foreach ($constructor?->getParameters() ?? [] as $parameter) { + if (!$parameter->isPromoted()) { + continue; + } + + $parameterName = $parameter->getName(); + $property = $targetRefl->getProperty($parameterName); + + if ($property->isReadOnly() && $property->isInitialized($mappedTarget)) { + continue; + } + + // this may be filled later on see storeValue + $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 ($refl === $targetRefl) { + $readMetadataFrom = $mappedTarget; + } + + $mapToProperties = []; + foreach ($refl->getProperties() as $property) { + if ($property->isStatic()) { + continue; + } + + $propertyName = $property->getName(); + $mappings = $this->metadataFactory->create($readMetadataFrom, $propertyName); + foreach ($mappings as $mapping) { + $sourcePropertyName = $propertyName; + if ($mapping->source && (!$refl->hasProperty($propertyName) || !isset($source->$propertyName))) { + $sourcePropertyName = $mapping->source; + } + + $value = $this->getRawValue($source, $sourcePropertyName); + if (($if = $mapping->if) && ($fn = $this->getCallable($if, $this->conditionCallableLocator)) && !$this->call($fn, $value, $source)) { + continue; + } + + if (false === $if) { + continue; + } + + $targetPropertyName = $mapping->target ?? $propertyName; + if (!$targetRefl->hasProperty($targetPropertyName)) { + continue; + } + + $value = $this->getSourceValue($source, $mappedTarget, $value, $this->objectMap, $mapping); + $this->storeValue($targetPropertyName, $mapToProperties, $ctorArguments, $value); + } + + if (!$mappings && $targetRefl->hasProperty($propertyName)) { + $value = $this->getSourceValue($source, $mappedTarget, $this->getRawValue($source, $propertyName), $this->objectMap); + $this->storeValue($propertyName, $mapToProperties, $ctorArguments, $value); + } + } + + if (!$mappingToObject && $ctorArguments && $constructor) { + try { + $mappedTarget->__construct(...$ctorArguments); + } catch (\ReflectionException $e) { + throw new MappingException($e->getMessage(), $e->getCode(), $e); + } + } + + foreach ($mapToProperties as $property => $value) { + $this->propertyAccessor ? $this->propertyAccessor->setValue($mappedTarget, $property, $value) : ($mappedTarget->{$property} = $value); + } + + if ($objectMapInitialized) { + $this->objectMap = null; + } + + return $mappedTarget; + } + + private function getRawValue(object $source, string $propertyName): mixed + { + return $this->propertyAccessor ? $this->propertyAccessor->getValue($source, $propertyName) : $source->{$propertyName}; + } + + private function getSourceValue(object $source, object $target, mixed $value, \SplObjectStorage $objectMap, ?Mapping $mapping = null): mixed + { + if ($mapping?->transform) { + $value = $this->applyTransforms($mapping, $value, $source); + } + + if ( + \is_object($value) + && ($innerMetadata = $this->metadataFactory->create($value)) + && ($mapTo = $this->getMapTarget($innerMetadata, $value, $source)) + && (\is_string($mapTo->target) && class_exists($mapTo->target)) + ) { + $value = $this->applyTransforms($mapTo, $value, $source); + + if ($value === $source) { + $value = $target; + } elseif ($objectMap->contains($value)) { + $value = $objectMap[$value]; + } else { + $value = $this->map($value, $mapTo->target); + } + } + + return $value; + } + + /** + * Store the value either the constructor arguments or as a property to be mapped. + * + * @param array $mapToProperties + * @param array $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 callable(): mixed $fn + */ + private function call(callable $fn, mixed $value, object $object): mixed + { + if (\is_string($fn)) { + return \call_user_func($fn, $value); + } + + return $fn($value, $object); + } + + /** + * @param Mapping[] $metadata + */ + private function getMapTarget(array $metadata, mixed $value, object $source): ?Mapping + { + $mapTo = null; + foreach ($metadata as $mapAttribute) { + if (($if = $mapAttribute->if) && ($fn = $this->getCallable($if, $this->conditionCallableLocator)) && !$this->call($fn, $value, $source)) { + continue; + } + + $mapTo = $mapAttribute; + } + + return $mapTo; + } + + private function applyTransforms(Mapping $map, mixed $value, object $object): mixed + { + if (!$transforms = $map->transform) { + return $value; + } + + if (\is_callable($transforms)) { + $transforms = [$transforms]; + } elseif (!\is_array($transforms)) { + $transforms = [$transforms]; + } + + foreach ($transforms as $transform) { + if ($fn = $this->getCallable($transform, $this->transformCallableLocator)) { + $value = $this->call($fn, $value, $object); + } + } + + return $value; + } + + /** + * @param (string|callable(mixed $value, object $object): mixed) $fn + */ + private function getCallable(string|callable $fn, ?ContainerInterface $locator = null): ?callable + { + if (\is_callable($fn)) { + return $fn; + } + + if ($locator?->has($fn)) { + return $locator->get($fn); + } + + return null; + } + + /** + * @param \ReflectionClass $targetRefl + * + * @return \ReflectionClass + */ + private function getSourceReflectionClass(object $source, \ReflectionClass $targetRefl): \ReflectionClass + { + $metadata = $this->metadataFactory->create($source); + try { + $refl = new \ReflectionClass($source); + } catch (\ReflectionException $e) { + throw new MappingException($e->getMessage(), $e->getCode(), $e); + } + + if ($metadata) { + return $refl; + } + + foreach ($refl->getProperties() as $property) { + if ($this->metadataFactory->create($source, $property)) { + return $refl; + } + } + + return $targetRefl; + } +} diff --git a/src/Symfony/Component/ObjectMapper/ObjectMapperInterface.php b/src/Symfony/Component/ObjectMapper/ObjectMapperInterface.php new file mode 100644 index 0000000000000..0df5a0fbfddbd --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/ObjectMapperInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper; + +use Symfony\Component\ObjectMapper\Exception\MappingException; +use Symfony\Component\ObjectMapper\Exception\MappingTransformException; + +/** + * Object to object mapper. + * + * @experimental + * + * @author Antoine Bluchet + */ +interface ObjectMapperInterface +{ + /** + * @template T of object + * + * @param object $source The object to map from + * @param T|class-string|null $target The object or class to map to + * + * @return T + * + * @throws MappingException When the mapping configuration is wrong + * @throws MappingTransformException When a transformation on an object does not return an object + */ + public function map(object $source, object|string|null $target = null): object; +} diff --git a/src/Symfony/Component/ObjectMapper/README.md b/src/Symfony/Component/ObjectMapper/README.md new file mode 100644 index 0000000000000..54c8191ad0dc4 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/README.md @@ -0,0 +1,19 @@ +Object Mapper Component +======================= + +The Object Mapper component allows you to map an object to another object, +facilitating the mapping using attributes. + +**This Component is experimental**. +[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/object-mapper.html) + * [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) diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/A.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/A.php new file mode 100644 index 0000000000000..9189f58353e8e --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/A.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(B::class)] +class A +{ + #[Map('bar')] + public string $foo; + + public string $baz; + + public string $notinb; + + #[Map(transform: 'strtoupper')] + public string $transform; + + #[Map(transform: [self::class, 'concatFn'])] + public ?string $concat = null; + + #[Map(if: 'boolval')] + public bool $nomap = false; + + public C $relation; + + public D $relationNotMapped; + + public function getConcat() + { + return 'should'; + } + + public static function concatFn($v, $object): string + { + return $v.$object->foo.$object->baz; + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/B.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/B.php new file mode 100644 index 0000000000000..ed0598049a8a2 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/B.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures; + +class B +{ + public function __construct(private string $bar) + { + } + public string $baz; + public string $transform; + public string $concat; + public bool $nomap = true; + public int $id; + public D $relation; + public D $relationNotMapped; +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/C.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/C.php new file mode 100644 index 0000000000000..1082d8b9791f0 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/C.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(D::class)] +class C +{ + public function __construct(#[Map('baz')] public readonly string $foo, #[Map('bat')] public readonly string $bar) + { + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ClassWithoutTarget.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ClassWithoutTarget.php new file mode 100644 index 0000000000000..2ec437023d138 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ClassWithoutTarget.php @@ -0,0 +1,7 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures; + +class D +{ + public function __construct(public string $baz, public string $bat) + { + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/DeeperRecursion/Recursive.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/DeeperRecursion/Recursive.php new file mode 100644 index 0000000000000..f86dad1c9e101 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/DeeperRecursion/Recursive.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\DeeperRecursion; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(RecursiveDto::class)] +class Recursive +{ + public string $name; + public Relation $relation; +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/DeeperRecursion/RecursiveDto.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/DeeperRecursion/RecursiveDto.php new file mode 100644 index 0000000000000..23360fe3dcdcb --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/DeeperRecursion/RecursiveDto.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\DeeperRecursion; + +class RecursiveDto +{ + public string $name; + public RelationDto $relation; +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/DeeperRecursion/Relation.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/DeeperRecursion/Relation.php new file mode 100644 index 0000000000000..a426b753f4d05 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/DeeperRecursion/Relation.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\DeeperRecursion; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(RelationDto::class)] +class Relation +{ + public Recursive $recursion; +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/DeeperRecursion/RelationDto.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/DeeperRecursion/RelationDto.php new file mode 100644 index 0000000000000..244e49ca0faa5 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/DeeperRecursion/RelationDto.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\DeeperRecursion; + +class RelationDto +{ + public RecursiveDto $recursion; +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Flatten/TargetUser.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Flatten/TargetUser.php new file mode 100644 index 0000000000000..88fbe52e33ccc --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Flatten/TargetUser.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\Flatten; + +class TargetUser +{ + public string $firstName; + public string $lastName; + public string $email; +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Flatten/User.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Flatten/User.php new file mode 100644 index 0000000000000..6dadd92bd4aad --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Flatten/User.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\Flatten; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(target: TargetUser::class)] +readonly class User +{ + public function __construct( + #[Map(transform: [UserProfile::class, 'getFirstName'], target: 'firstName')] + #[Map(transform: [UserProfile::class, 'getLastName'], target: 'lastName')] + public UserProfile $profile, + public string $email, + ) { + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Flatten/UserProfile.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Flatten/UserProfile.php new file mode 100644 index 0000000000000..18476b0a98e2e --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Flatten/UserProfile.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\Flatten; + +readonly class UserProfile +{ + public function __construct(public string $firstName, public string $lastName) + { + } + + public static function getFirstName($v, $object) + { + return $v->firstName; + } + + public static function getLastName($v, $object) + { + return $v->lastName; + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/HydrateObject/SourceOnly.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/HydrateObject/SourceOnly.php new file mode 100644 index 0000000000000..9e3127b80d965 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/HydrateObject/SourceOnly.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\HydrateObject; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +class SourceOnly +{ + public function __construct(#[Map(source: 'name')] public string $mappedName) + { + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/InstanceCallback/A.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/InstanceCallback/A.php new file mode 100644 index 0000000000000..6b4fc4bb1c3dd --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/InstanceCallback/A.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\InstanceCallback; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(transform: [B::class, 'newInstance'])] +class A +{ + public string $name = 'test'; +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/InstanceCallback/B.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/InstanceCallback/B.php new file mode 100644 index 0000000000000..d9d7b829599bc --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/InstanceCallback/B.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\InstanceCallback; + +class B +{ + public ?string $name = null; + + public function __construct(private readonly int $id) + { + } + + public function getId(): int + { + return $this->id; + } + + public static function newInstance(): self + { + return new self(1); + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/AToBMapper.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/AToBMapper.php new file mode 100644 index 0000000000000..c532a94752281 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/AToBMapper.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\MapStruct; + +use Symfony\Component\ObjectMapper\ObjectMapper; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +#[Map(source: Source::class, target: Target::class)] +class AToBMapper implements ObjectMapperInterface +{ + public function __construct(private readonly ObjectMapper $objectMapper) + { + } + + // TODO: change attribute + #[Map(source: 'propertyA', target: 'propertyD')] + #[Map(source: 'propertyB', if: false)] + public function map(object $source, object|string|null $target = null): object + { + return $this->objectMapper->map($source, $target); + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/Map.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/Map.php new file mode 100644 index 0000000000000..8dd0ead33bdf9 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/Map.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\MapStruct; + +use Symfony\Component\ObjectMapper\Attribute\Map as AttributeMap; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +readonly class Map extends AttributeMap +{ +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/MapStructMapperMetadataFactory.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/MapStructMapperMetadataFactory.php new file mode 100644 index 0000000000000..7baa382636bf7 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/MapStructMapperMetadataFactory.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\MapStruct; + +use Symfony\Component\ObjectMapper\Metadata\Mapping; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +/** + * A Metadata factory that implements the basics behind https://mapstruct.org/. + * + * @author Antoine Bluchet + */ +final class MapStructMapperMetadataFactory implements ObjectMapperMetadataFactoryInterface +{ + public function __construct(private readonly string $mapper) + { + if (!is_a($mapper, ObjectMapperInterface::class, true)) { + throw new \RuntimeException(\sprintf('Mapper should implement "%s".', ObjectMapperInterface::class)); + } + } + + public function create(object $object, ?string $property = null, array $context = []): array + { + $refl = new \ReflectionClass($this->mapper); + $mapTo = []; + $source = $property ?? $object::class; + foreach (($property ? $refl->getMethod('map') : $refl)->getAttributes(Map::class) as $mappingAttribute) { + $map = $mappingAttribute->newInstance(); + if ($map->source === $source) { + $mapTo[] = new Mapping(source: $map->source, target: $map->target, if: $map->if, transform: $map->transform); + + continue; + } + } + + // Default is to map properties to a property of the same name + if (!$mapTo && $property) { + $mapTo[] = new Mapping(source: $property, target: $property); + } + + return $mapTo; + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/Source.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/Source.php new file mode 100644 index 0000000000000..0a7d8b6dbaefc --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/Source.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\MapStruct; + +class Source +{ + public function __construct(public readonly string $propertyA, public readonly string $propertyB, public readonly string $propertyC) + { + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/Target.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/Target.php new file mode 100644 index 0000000000000..b555ed58cf391 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/Target.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\MapStruct; + +class Target +{ + public string $propertyC; + // should be mapped from A + public string $propertyD; +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MultipleTargets/A.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MultipleTargets/A.php new file mode 100644 index 0000000000000..9ce274032c398 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MultipleTargets/A.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\MultipleTargets; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(target: B::class, if: [A::class, 'shouldMapToB'])] +#[Map(target: C::class, if: [A::class, 'shouldMapToC'])] +class A +{ + public function __construct(public readonly string $foo = 'bar') + { + } + + public static function shouldMapToB(mixed $value, object $object): bool + { + return false; + } + + public static function shouldMapToC(mixed $value, object $object): bool + { + return true; + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MultipleTargets/B.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MultipleTargets/B.php new file mode 100644 index 0000000000000..cc5c3cd6a9472 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MultipleTargets/B.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\MultipleTargets; + +class B +{ +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MultipleTargets/C.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MultipleTargets/C.php new file mode 100644 index 0000000000000..ec29d1460c9a7 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MultipleTargets/C.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\MultipleTargets; + +class C +{ + public function __construct(public readonly string $foo) + { + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Recursion/AB.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Recursion/AB.php new file mode 100644 index 0000000000000..3a5efe630b834 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Recursion/AB.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\Recursion; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(Dto::class)] +class AB +{ + #[Map('dto')] + public AB $ab; +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Recursion/Dto.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Recursion/Dto.php new file mode 100644 index 0000000000000..40db3df292fbd --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/Recursion/Dto.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\Recursion; + +class Dto +{ + public Dto $dto; +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ServiceLocator/A.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ServiceLocator/A.php new file mode 100644 index 0000000000000..9c819520e31b9 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ServiceLocator/A.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(B::class)] +class A +{ + #[Map(target: 'bar', transform: TransformCallable::class, if: ConditionCallable::class)] + public string $foo; +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ServiceLocator/B.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ServiceLocator/B.php new file mode 100644 index 0000000000000..0b27f1002279f --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ServiceLocator/B.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator; + +class B +{ + public string $bar = 'notmapped'; +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ServiceLocator/ConditionCallable.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ServiceLocator/ConditionCallable.php new file mode 100644 index 0000000000000..b7d42889e3742 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ServiceLocator/ConditionCallable.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator; + +use Symfony\Component\ObjectMapper\ConditionCallableInterface; + +/** + * @implements ConditionCallableInterface + */ +class ConditionCallable implements ConditionCallableInterface +{ + public function __invoke(mixed $value, object $object): bool + { + return 'ok' === $value; + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ServiceLocator/TransformCallable.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ServiceLocator/TransformCallable.php new file mode 100644 index 0000000000000..2d34e696e8fc4 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/ServiceLocator/TransformCallable.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator; + +use Symfony\Component\ObjectMapper\TransformCallableInterface; + +/** + * @implements TransformCallableInterface + */ +class TransformCallable implements TransformCallableInterface +{ + public function __invoke(mixed $value, object $object): mixed + { + return "transformed$value"; + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/ObjectMapperTest.php b/src/Symfony/Component/ObjectMapper/Tests/ObjectMapperTest.php new file mode 100644 index 0000000000000..e9ecc2e5dbfb6 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/ObjectMapperTest.php @@ -0,0 +1,266 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests; + +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\ObjectMapper\Exception\MappingException; +use Symfony\Component\ObjectMapper\Exception\MappingTransformException; +use Symfony\Component\ObjectMapper\Metadata\Mapping; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; +use Symfony\Component\ObjectMapper\ObjectMapper; +use Symfony\Component\ObjectMapper\Tests\Fixtures\A; +use Symfony\Component\ObjectMapper\Tests\Fixtures\B; +use Symfony\Component\ObjectMapper\Tests\Fixtures\C; +use Symfony\Component\ObjectMapper\Tests\Fixtures\ClassWithoutTarget; +use Symfony\Component\ObjectMapper\Tests\Fixtures\D; +use Symfony\Component\ObjectMapper\Tests\Fixtures\DeeperRecursion\Recursive; +use Symfony\Component\ObjectMapper\Tests\Fixtures\DeeperRecursion\RecursiveDto; +use Symfony\Component\ObjectMapper\Tests\Fixtures\DeeperRecursion\Relation; +use Symfony\Component\ObjectMapper\Tests\Fixtures\DeeperRecursion\RelationDto; +use Symfony\Component\ObjectMapper\Tests\Fixtures\Flatten\TargetUser; +use Symfony\Component\ObjectMapper\Tests\Fixtures\Flatten\User; +use Symfony\Component\ObjectMapper\Tests\Fixtures\Flatten\UserProfile; +use Symfony\Component\ObjectMapper\Tests\Fixtures\HydrateObject\SourceOnly; +use Symfony\Component\ObjectMapper\Tests\Fixtures\InstanceCallback\A as InstanceCallbackA; +use Symfony\Component\ObjectMapper\Tests\Fixtures\InstanceCallback\B as InstanceCallbackB; +use Symfony\Component\ObjectMapper\Tests\Fixtures\MapStruct\AToBMapper; +use Symfony\Component\ObjectMapper\Tests\Fixtures\MapStruct\MapStructMapperMetadataFactory; +use Symfony\Component\ObjectMapper\Tests\Fixtures\MapStruct\Source; +use Symfony\Component\ObjectMapper\Tests\Fixtures\MapStruct\Target; +use Symfony\Component\ObjectMapper\Tests\Fixtures\MultipleTargets\A as MultipleTargetsA; +use Symfony\Component\ObjectMapper\Tests\Fixtures\MultipleTargets\C as MultipleTargetsC; +use Symfony\Component\ObjectMapper\Tests\Fixtures\Recursion\AB; +use Symfony\Component\ObjectMapper\Tests\Fixtures\Recursion\Dto; +use Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator\A as ServiceLocatorA; +use Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator\B as ServiceLocatorB; +use Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator\ConditionCallable; +use Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator\TransformCallable; +use Symfony\Component\PropertyAccess\PropertyAccess; + +final class ObjectMapperTest extends TestCase +{ + /** + * @dataProvider mapProvider + */ + public function testMap($expect, $args, array $deps = []) + { + $mapper = new ObjectMapper(...$deps); + $this->assertEquals($expect, $mapper->map(...$args)); + } + + /** + * @return iterable + */ + public static function mapProvider(): iterable + { + $d = new D(baz: 'foo', bat: 'bar'); + $c = new C(foo: 'foo', bar: 'bar'); + $a = new A(); + $a->foo = 'test'; + $a->transform = 'test'; + $a->baz = 'me'; + $a->notinb = 'test'; + $a->relation = $c; + $a->relationNotMapped = $d; + + $b = new B('test'); + $b->transform = 'TEST'; + $b->baz = 'me'; + $b->nomap = true; + $b->concat = 'testme'; + $b->relation = $d; + $b->relationNotMapped = $d; + yield [$b, [$a]]; + + $c = clone $b; + $c->id = 1; + yield [$c, [$a, $c]]; + + $d = clone $b; + // with propertyAccessor we call the getter + $d->concat = 'shouldtestme'; + + yield [$d, [$a], [new ReflectionObjectMapperMetadataFactory(), PropertyAccess::createPropertyAccessor()]]; + + yield [new MultipleTargetsC(foo: 'bar'), [new MultipleTargetsA()]]; + } + + public function testHasNothingToMapTo() + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage('Mapping target not found for source "class@anonymous".'); + (new ObjectMapper())->map(new class () {}); + } + + public function testHasNothingToMapToWithNamedClass() + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage(\sprintf('Mapping target not found for source "%s".', ClassWithoutTarget::class)); + (new ObjectMapper())->map(new ClassWithoutTarget()); + } + + public function testTargetNotFound() + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage(\sprintf('Mapping target class "InexistantClass" does not exist for source "%s".', ClassWithoutTarget::class)); + (new ObjectMapper())->map(new ClassWithoutTarget(), 'InexistantClass'); + } + + public function testRecursion() + { + $ab = new AB(); + $ab->ab = $ab; + $mapper = new ObjectMapper(); + $mapped = $mapper->map($ab); + $this->assertInstanceOf(Dto::class, $mapped); + $this->assertSame($mapped, $mapped->dto); + } + + public function testDeeperRecursion() + { + $recursive = new Recursive(); + $recursive->name = 'hi'; + $recursive->relation = new Relation(); + $recursive->relation->recursion = $recursive; + $mapper = new ObjectMapper(); + $mapped = $mapper->map($recursive); + $this->assertSame($mapped->relation->recursion, $mapped); + $this->assertInstanceOf(RecursiveDto::class, $mapped); + $this->assertInstanceOf(RelationDto::class, $mapped->relation); + } + + public function testMapToWithInstanceHook() + { + $a = new InstanceCallbackA(); + $mapper = new ObjectMapper(); + $b = $mapper->map($a, InstanceCallbackB::class); + $this->assertInstanceOf(InstanceCallbackB::class, $b); + $this->assertSame($b->getId(), 1); + $this->assertSame($b->name, 'test'); + } + + public function testMapStruct() + { + $a = new Source('a', 'b', 'c'); + $metadata = new MapStructMapperMetadataFactory(AToBMapper::class); + $mapper = new ObjectMapper($metadata); + $aToBMapper = new AToBMapper($mapper); + $b = $aToBMapper->map($a); + $this->assertInstanceOf(Target::class, $b); + $this->assertSame($b->propertyD, 'a'); + $this->assertSame($b->propertyC, 'c'); + } + + public function testMultipleMapProperty() + { + $u = new User(email: 'hello@example.com', profile: new UserProfile(firstName: 'soyuka', lastName: 'arakusa')); + $mapper = new ObjectMapper(); + $b = $mapper->map($u); + $this->assertInstanceOf(TargetUser::class, $b); + $this->assertSame($b->firstName, 'soyuka'); + $this->assertSame($b->lastName, 'arakusa'); + } + + public function testServiceLocator() + { + $a = new ServiceLocatorA(); + $a->foo = 'nok'; + + $mapper = new ObjectMapper( + conditionCallableLocator: $this->getServiceLocator([ConditionCallable::class => new ConditionCallable()]), + transformCallableLocator: $this->getServiceLocator([TransformCallable::class => new TransformCallable()]) + ); + + $b = $mapper->map($a); + $this->assertSame($b->bar, 'notmapped'); + $this->assertInstanceOf(ServiceLocatorB::class, $b); + + $a->foo = 'ok'; + $b = $mapper->map($a); + $this->assertInstanceOf(ServiceLocatorB::class, $b); + $this->assertSame($b->bar, 'transformedok'); + } + + protected function getServiceLocator(array $factories): ContainerInterface + { + return new class ($factories) implements ContainerInterface { + public function __construct(private array $factories) + { + } + + public function has(string $id): bool + { + return isset($this->factories[$id]); + } + + public function get(string $id): mixed + { + return $this->factories[$id]; + } + }; + } + + public function testSourceOnly(): void + { + $a = new \stdClass(); + $a->name = 'test'; + $mapper = new ObjectMapper(); + $mapped = $mapper->map($a, SourceOnly::class); + $this->assertInstanceOf(SourceOnly::class, $mapped); + $this->assertSame('test', $mapped->mappedName); + + $a = new class () { + public function __get(string $key): string + { + return match ($key) { + 'name' => 'test', + default => throw new \LogicException($key), + }; + } + }; + + $mapped = $mapper->map($a, SourceOnly::class); + $this->assertInstanceOf(SourceOnly::class, $mapped); + $this->assertSame('test', $mapped->mappedName); + } + + + public function testTransformToWrongValueType(): void + { + $this->expectException(MappingTransformException::class); + $this->expectExceptionMessage('Cannot map "stdClass" to a non-object target of type "string".'); + + $u = new \stdClass; + $u->foo = 'bar'; + + $metadata = $this->createStub(ObjectMapperMetadataFactoryInterface::class); + $metadata->method('create')->with($u)->willReturn([new Mapping(target: \stdClass::class, transform: fn() => 'str')]); + $mapper = new ObjectMapper($metadata); + $mapper->map($u); + } + + public function testTransformToWrongObject(): void + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage(sprintf('Expected the mapped object to be an instance of "%s" but got "stdClass".', ClassWithoutTarget::class)); + + $u = new \stdClass; + $u->foo = 'bar'; + + $metadata = $this->createStub(ObjectMapperMetadataFactoryInterface::class); + $metadata->method('create')->with($u)->willReturn([new Mapping(target: ClassWithoutTarget::class, transform: fn() => new \stdClass)]); + $mapper = new ObjectMapper($metadata); + $mapper->map($u); + } +} diff --git a/src/Symfony/Component/ObjectMapper/TransformCallableInterface.php b/src/Symfony/Component/ObjectMapper/TransformCallableInterface.php new file mode 100644 index 0000000000000..faf66586c5451 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/TransformCallableInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper; + +/** + * Service used by "Map::transform". + * + * @template T of object + * + * @experimental + * + * {@see Symfony\Component\ObjectMapper\Attribute\Map} + */ +interface TransformCallableInterface +{ + /** + * @param mixed $value The value being mapped + * @param T $object The object we're working on + */ + public function __invoke(mixed $value, object $object): mixed; +} diff --git a/src/Symfony/Component/ObjectMapper/composer.json b/src/Symfony/Component/ObjectMapper/composer.json new file mode 100644 index 0000000000000..eb89582d8aad6 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/composer.json @@ -0,0 +1,36 @@ +{ + "name": "symfony/object-mapper", + "type": "library", + "description": "Provides a way to map an object to another object", + "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", + "psr/container": "^2.0" + }, + "require-dev": { + "symfony/property-access": "^7.2" + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\ObjectMapper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "conflict": { + "symfony/property-access": "<7.2" + } +} diff --git a/src/Symfony/Component/ObjectMapper/phpunit.xml.dist b/src/Symfony/Component/ObjectMapper/phpunit.xml.dist new file mode 100644 index 0000000000000..403928c6487ea --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + +