From 4740a9661c7aebc32641df55d0ae00b777e58070 Mon Sep 17 00:00:00 2001 From: Renaud Date: Sun, 15 Feb 2026 19:55:11 +0100 Subject: [PATCH 01/14] First working version --- README.md | 49 +++ coverage.xml | 346 ++++++++++++++++++++ src/FlatMapper.php | 367 ++++++++++++++++++++-- src/PixelshapedFlatMapperBundle.php | 11 +- tests/Examples/Valid/Yaml/AuthorDTO.php | 16 + tests/Examples/Valid/Yaml/BookDTO.php | 13 + tests/FlatMapperCreateMappingTest.php | 252 +++++++++++++++ tests/FlatMapperTest.php | 77 +++++ tests/Functional/BundleFunctionalTest.php | 43 +++ tests/PixelshapedFlatMapperBundleTest.php | 125 ++++++++ 10 files changed, 1280 insertions(+), 19 deletions(-) create mode 100644 coverage.xml create mode 100644 tests/Examples/Valid/Yaml/AuthorDTO.php create mode 100644 tests/Examples/Valid/Yaml/BookDTO.php create mode 100644 tests/PixelshapedFlatMapperBundleTest.php diff --git a/README.md b/README.md index 330bf56..5c70cfa 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,39 @@ class UserDTO { Individual `#[Scalar]` or `#[Identifier]` attributes override class-level transformations. +### YAML Mappings + +You can also define mappings in YAML (or any PHP array) and FlatMapper will parse them through the same mapping logic as attributes. + +If both YAML and PHP attributes are defined for the same DTO/property attribute, PHP attributes take precedence. + +```yaml +pixelshaped_flat_mapper: + mappings: + App\DTO\AuthorDTO: + class: + NameTransformation: + columnPrefix: 'author_' + properties: + id: + Identifier: ~ + books: + ReferenceArray: App\DTO\BookDTO + App\DTO\BookDTO: + class: + NameTransformation: + columnPrefix: 'book_' + snakeCaseColumns: true + properties: + id: + Identifier: ~ +``` + +YAML attribute arguments support: +- `null` for no argument (example: `Identifier: ~`) +- scalar value for one positional argument (example: `Scalar: author_id`) +- array for positional or named arguments (example: `NameTransformation: { columnPrefix: 'book_', snakeCaseColumns: true }`) + ## Complete Examples ### Nested DTOs Example @@ -354,6 +387,12 @@ FlatMapper works out of the box with Symfony. Optionally configure for better pe pixelshaped_flat_mapper: validate_mapping: '%kernel.debug%' # Disable validation in production cache_service: cache.app # Cache mapping metadata + mappings: + App\DTO\AuthorDTO: + properties: + id: + Identifier: ~ + Scalar: author_id ``` ### Doctrine @@ -400,6 +439,16 @@ $flatMapper ->setCacheService($psr6CachePool) // Any PSR-6 cache ->setValidateMapping(false); // Skip validation checks +// Optional: declare mappings outside PHP attributes +$flatMapper->setYamlMappings([ + AuthorDTO::class => [ + 'properties' => [ + 'id' => ['Identifier' => null, 'Scalar' => 'author_id'], + 'books' => ['ReferenceArray' => BookDTO::class], + ], + ], +]); + $result = $flatMapper->map(AuthorDTO::class, $queryResults); ``` diff --git a/coverage.xml b/coverage.xml new file mode 100644 index 0000000..28bcae1 --- /dev/null +++ b/coverage.xml @@ -0,0 +1,346 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/FlatMapper.php b/src/FlatMapper.php index 10326b2..b4354e1 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -21,6 +21,7 @@ final class FlatMapper private const SNAKE_CASE_PATTERN_1 = '/([A-Z]+)([A-Z][a-z])/'; private const SNAKE_CASE_PATTERN_2 = '/([a-z\d])([A-Z])/'; private const SNAKE_CASE_REPLACEMENT = '\1_\2'; + private const MAPPING_NAMESPACE_PREFIX = 'Pixelshaped\\FlatMapperBundle\\Mapping\\'; /** * @var array> @@ -30,6 +31,13 @@ final class FlatMapper * @var array>> */ private array $objectsMapping = []; + /** + * @var array, + * properties?: array> + * }> + */ + private array $yamlMappings = []; private ?CacheInterface $cacheService = null; private bool $validateMapping = true; @@ -44,6 +52,21 @@ public function setValidateMapping(bool $validateMapping): void $this->validateMapping = $validateMapping; } + /** + * @param array, + * properties?: array> + * }> $yamlMappings + */ + public function setYamlMappings(array $yamlMappings): void + { + $this->yamlMappings = $yamlMappings; + + // Mapping source changed, so in-memory compiled mappings need to be rebuilt. + $this->objectIdentifiers = []; + $this->objectsMapping = []; + } + public function createMapping(string $dtoClassName): void { if(!class_exists($dtoClassName)) { @@ -52,8 +75,7 @@ public function createMapping(string $dtoClassName): void if(!isset($this->objectsMapping[$dtoClassName])) { if($this->cacheService !== null) { - $cacheKey = strtr($dtoClassName, ['\\' => '_', '-' => '_', ' ' => '_']); - $mappingInfo = $this->cacheService->get('pixelshaped_flat_mapper_'.$cacheKey, function () use ($dtoClassName): array { + $mappingInfo = $this->cacheService->get($this->createCacheKey($dtoClassName), function () use ($dtoClassName): array { return $this->createMappingRecursive($dtoClassName); }); } else { @@ -89,11 +111,16 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde $identifiersCount = 0; $transformation = null; - foreach ($reflectionClass->getAttributes() as $attribute) { - switch ($attribute->getName()) { + foreach ($this->getClassMappingAttributes($reflectionClass) as $attribute) { + switch ($attribute['name']) { case Identifier::class: - if (isset($attribute->getArguments()[0]) && $attribute->getArguments()[0] !== null) { - $objectIdentifiers[$dtoClassName] = $attribute->getArguments()[0]; + $identifierPropertyName = $this->getStringAttributeArgument( + $attribute, + 'mappedPropertyName', + sprintf('class "%s"', $dtoClassName) + ); + if ($identifierPropertyName !== null) { + $objectIdentifiers[$dtoClassName] = $identifierPropertyName; $identifiersCount++; } else { throw new MappingCreationException('The Identifier attribute cannot be used without a property name when used as a Class attribute'); @@ -102,8 +129,9 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde case NameTransformation::class: try { - /** @var NameTransformation $transformation */ - $transformation = $attribute->newInstance(); + /** @var NameTransformation $transformationInstance */ + $transformationInstance = $this->newMappingAttributeInstance($attribute); + $transformation = $transformationInstance; } catch (Error $e) { throw new MappingCreationException(sprintf( 'Invalid NameTransformation attribute for %s:%s%s', @@ -121,26 +149,66 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde ? $this->transformPropertyName($propertyName, $transformation) : $propertyName; $isIdentifier = false; - foreach ($reflectionParameter->getAttributes() as $attribute) { - if ($attribute->getName() === ReferenceArray::class || $attribute->getName() === ScalarArray::class) { + foreach ($this->getPropertyMappingAttributes($dtoClassName, $propertyName, $reflectionParameter->getAttributes()) as $attribute) { + if ($attribute['name'] === ReferenceArray::class || $attribute['name'] === ScalarArray::class) { + $mappingArgumentName = $attribute['name'] === ReferenceArray::class + ? 'referenceClassName' + : 'mappedPropertyName'; + $mappedProperty = $this->getStringAttributeArgument( + $attribute, + $mappingArgumentName, + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + + if($mappedProperty === null) { + throw new MappingCreationException(sprintf( + 'Attribute "%s" on property "%s::$%s" requires a mapped value.', + $attribute['name'], + $dtoClassName, + $propertyName + )); + } + if($this->validateMapping) { if((new ReflectionProperty($dtoClassName, $propertyName))->isReadOnly()) { throw new MappingCreationException($reflectionClass->getName().': property '.$propertyName.' cannot be readonly as it is non-scalar and '.static::class.' needs to access it after object instantiation.'); } } - $objectsMapping[$dtoClassName][$propertyName] = (string)$attribute->getArguments()[0]; - if($attribute->getName() === ReferenceArray::class) { - $this->createMappingRecursive($attribute->getArguments()[0], $objectIdentifiers, $objectsMapping); + $objectsMapping[$dtoClassName][$propertyName] = $mappedProperty; + if($attribute['name'] === ReferenceArray::class) { + if(!class_exists($mappedProperty)) { + throw new MappingCreationException(sprintf( + 'Invalid reference class "%s" configured on property "%s::$%s".', + $mappedProperty, + $dtoClassName, + $propertyName + )); + } + + /** @var class-string $mappedProperty */ + $this->createMappingRecursive($mappedProperty, $objectIdentifiers, $objectsMapping); } continue 2; - } else if ($attribute->getName() === Identifier::class) { + } else if ($attribute['name'] === Identifier::class) { $identifiersCount++; $isIdentifier = true; - if(isset($attribute->getArguments()[0]) && $attribute->getArguments()[0] !== null) { - $columnName = $attribute->getArguments()[0]; + $identifierColumnName = $this->getStringAttributeArgument( + $attribute, + 'mappedPropertyName', + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + if($identifierColumnName !== null) { + $columnName = $identifierColumnName; + } + } else if ($attribute['name'] === Scalar::class) { + $scalarColumnName = $this->getStringAttributeArgument( + $attribute, + 'mappedPropertyName', + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + if($scalarColumnName !== null) { + $columnName = $scalarColumnName; } - } else if ($attribute->getName() === Scalar::class) { - $columnName = $attribute->getArguments()[0]; } } @@ -183,6 +251,269 @@ private function transformPropertyName(string $propertyName, NameTransformation return $transformation->columnPrefix . $propertyName; } + /** + * Keep cache keys deterministic while invalidating when YAML mapping changes. + */ + private function createCacheKey(string $dtoClassName): string + { + $cacheKey = strtr($dtoClassName, ['\\' => '_', '-' => '_', ' ' => '_']); + $mappingHash = md5(serialize($this->yamlMappings[$dtoClassName] ?? [])); + + return 'pixelshaped_flat_mapper_'.$cacheKey.'_'.$mappingHash; + } + + /** + * @param ReflectionClass $reflectionClass + * @return list, + * reflectionAttribute?: \ReflectionAttribute + * }> + */ + private function getClassMappingAttributes(ReflectionClass $reflectionClass): array + { + return $this->mergeMappingAttributes( + $reflectionClass->getName(), + $reflectionClass->getAttributes() + ); + } + + /** + * @param list<\ReflectionAttribute> $reflectionAttributes + * @return list, + * reflectionAttribute?: \ReflectionAttribute + * }> + */ + private function getPropertyMappingAttributes(string $dtoClassName, string $propertyName, array $reflectionAttributes): array + { + return $this->mergeMappingAttributes( + $dtoClassName, + $reflectionAttributes, + $propertyName + ); + } + + /** + * @param list<\ReflectionAttribute> $reflectionAttributes + * @return list, + * reflectionAttribute?: \ReflectionAttribute + * }> + */ + private function mergeMappingAttributes(string $dtoClassName, array $reflectionAttributes, ?string $propertyName = null): array + { + $mappingAttributes = []; + + foreach ($this->getYamlAttributes($dtoClassName, $propertyName) as $attributeName => $attributeArguments) { + $mappingAttributes[$attributeName] = [ + 'name' => $attributeName, + 'arguments' => $attributeArguments, + ]; + } + + foreach ($reflectionAttributes as $reflectionAttribute) { + $attributeName = $reflectionAttribute->getName(); + if (isset($mappingAttributes[$attributeName])) { + // Ensure reflection attributes override YAML and keep declaration ordering. + unset($mappingAttributes[$attributeName]); + } + $mappingAttributes[$attributeName] = [ + 'name' => $attributeName, + 'arguments' => $reflectionAttribute->getArguments(), + 'reflectionAttribute' => $reflectionAttribute, + ]; + } + + return array_values($mappingAttributes); + } + + /** + * @return array> + */ + private function getYamlAttributes(string $dtoClassName, ?string $propertyName = null): array + { + if (!isset($this->yamlMappings[$dtoClassName])) { + return []; + } + + $classMapping = $this->yamlMappings[$dtoClassName]; + if (!is_array($classMapping)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for class "%s". Expected an array.', + $dtoClassName + )); + } + + if ($propertyName === null) { + $rawAttributes = $classMapping['class'] ?? []; + return $this->normalizeYamlAttributeMap( + $rawAttributes, + sprintf('class "%s"', $dtoClassName) + ); + } + + $rawProperties = $classMapping['properties'] ?? []; + if (!is_array($rawProperties)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for class "%s". The "properties" section must be an array.', + $dtoClassName + )); + } + + $rawAttributes = $rawProperties[$propertyName] ?? []; + return $this->normalizeYamlAttributeMap( + $rawAttributes, + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + } + + /** + * @return array> + */ + private function normalizeYamlAttributeMap(mixed $rawAttributes, string $mappingTarget): array + { + if ($rawAttributes === null) { + return []; + } + + if (!is_array($rawAttributes)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Expected an attribute map array.', + $mappingTarget + )); + } + + $normalizedAttributes = []; + foreach ($rawAttributes as $attributeName => $rawArguments) { + if (!is_string($attributeName)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Attribute names must be strings.', + $mappingTarget + )); + } + + $resolvedAttributeName = $this->resolveAttributeClassName($attributeName, $mappingTarget); + $normalizedAttributes[$resolvedAttributeName] = $this->normalizeYamlAttributeArguments( + $rawArguments, + $attributeName, + $mappingTarget + ); + } + + return $normalizedAttributes; + } + + /** + * @return array + */ + private function normalizeYamlAttributeArguments(mixed $rawArguments, string $attributeName, string $mappingTarget): array + { + if ($rawArguments === null) { + return []; + } + + if (is_scalar($rawArguments)) { + return [$rawArguments]; + } + + if (is_array($rawArguments)) { + return $rawArguments; + } + + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for attribute "%s" on %s. Expected null, scalar, or array arguments.', + $attributeName, + $mappingTarget + )); + } + + /** + * @return class-string + */ + private function resolveAttributeClassName(string $attributeName, string $mappingTarget): string + { + $className = str_contains($attributeName, '\\') + ? $attributeName + : self::MAPPING_NAMESPACE_PREFIX.$attributeName; + + if (!class_exists($className)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Attribute class "%s" does not exist.', + $mappingTarget, + $className + )); + } + + return $className; + } + + /** + * @param array{ + * name: class-string, + * arguments: array, + * reflectionAttribute?: \ReflectionAttribute + * } $attribute + */ + private function getAttributeArgument(array $attribute, string $namedArgument, int $position = 0): mixed + { + if (array_key_exists($position, $attribute['arguments'])) { + return $attribute['arguments'][$position]; + } + + if (array_key_exists($namedArgument, $attribute['arguments'])) { + return $attribute['arguments'][$namedArgument]; + } + + return null; + } + + /** + * @param array{ + * name: class-string, + * arguments: array, + * reflectionAttribute?: \ReflectionAttribute + * } $attribute + */ + private function getStringAttributeArgument(array $attribute, string $namedArgument, string $mappingTarget, int $position = 0): ?string + { + $argument = $this->getAttributeArgument($attribute, $namedArgument, $position); + if ($argument === null) { + return null; + } + + if (!is_string($argument)) { + throw new MappingCreationException(sprintf( + 'Invalid %s argument for attribute "%s" on %s. Expected string, got %s.', + $namedArgument, + $attribute['name'], + $mappingTarget, + get_debug_type($argument) + )); + } + + return $argument; + } + + /** + * @param array{ + * name: class-string, + * arguments: array, + * reflectionAttribute?: \ReflectionAttribute + * } $attribute + */ + private function newMappingAttributeInstance(array $attribute): object + { + if (isset($attribute['reflectionAttribute'])) { + return $attribute['reflectionAttribute']->newInstance(); + } + + $attributeClassName = $attribute['name']; + return new $attributeClassName(...$attribute['arguments']); + } + /** * @template T of object * @param class-string $dtoClassName diff --git a/src/PixelshapedFlatMapperBundle.php b/src/PixelshapedFlatMapperBundle.php index 1502614..68e2829 100644 --- a/src/PixelshapedFlatMapperBundle.php +++ b/src/PixelshapedFlatMapperBundle.php @@ -6,13 +6,14 @@ use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; class PixelshapedFlatMapperBundle extends AbstractBundle { /** - * @param array $config + * @param array $config */ public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { @@ -20,11 +21,18 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $flatMapper = $builder->getDefinition('pixelshaped_flat_mapper.flat_mapper'); if($config['cache_service'] !== null) { + if(!is_string($config['cache_service'])) { + throw new InvalidArgumentException('The "cache_service" option must be a string or null.'); + } + $flatMapper->addMethodCall('setCacheService', [new Reference($config['cache_service'])]); } if($config['validate_mapping'] !== null) { $flatMapper->addMethodCall('setValidateMapping', [$config['validate_mapping']]); } + if($config['mappings'] !== null) { + $flatMapper->addMethodCall('setYamlMappings', [$config['mappings']]); + } } public function configure(DefinitionConfigurator $definition): void @@ -33,6 +41,7 @@ public function configure(DefinitionConfigurator $definition): void ->children() ->booleanNode('validate_mapping')->defaultTrue()->end() ->scalarNode('cache_service')->defaultNull()->end() + ->arrayNode('mappings')->defaultValue([])->normalizeKeys(false)->prototype('variable')->end()->end() ; } diff --git a/tests/Examples/Valid/Yaml/AuthorDTO.php b/tests/Examples/Valid/Yaml/AuthorDTO.php new file mode 100644 index 0000000..f9a3ecd --- /dev/null +++ b/tests/Examples/Valid/Yaml/AuthorDTO.php @@ -0,0 +1,16 @@ + $books + */ + public function __construct( + public int $id, + public string $name, + public array $books, + ) {} +} diff --git a/tests/Examples/Valid/Yaml/BookDTO.php b/tests/Examples/Valid/Yaml/BookDTO.php new file mode 100644 index 0000000..00f5ee5 --- /dev/null +++ b/tests/Examples/Valid/Yaml/BookDTO.php @@ -0,0 +1,13 @@ +expectExceptionMessageMatches("/Invalid NameTransformation attribute/"); (new FlatMapper())->createMapping(InvalidNameTransformationDTO::class); } + + public function testCreateMappingWithYamlMappingsDoesNotAssert(): void + { + $flatMapper = new FlatMapper(); + $flatMapper->setYamlMappings([ + YamlAuthorDTO::class => [ + 'class' => [ + 'NameTransformation' => ['columnPrefix' => 'author_'], + ], + 'properties' => [ + 'id' => ['Identifier' => null], + 'books' => ['ReferenceArray' => YamlBookDTO::class], + ], + ], + YamlBookDTO::class => [ + 'class' => [ + 'NameTransformation' => ['columnPrefix' => 'book_', 'snakeCaseColumns' => true], + ], + 'properties' => [ + 'id' => ['Identifier' => null], + ], + ], + ]); + + $this->expectNotToPerformAssertions(); + $flatMapper->createMapping(YamlAuthorDTO::class); + } + + public function testCreateMappingWithInvalidYamlMappingShapeAsserts(): void + { + $flatMapper = new FlatMapper(); + // @phpstan-ignore argument.type + $flatMapper->setYamlMappings([ + YamlAuthorDTO::class => [ + 'properties' => [ + 'id' => 'invalid', + ], + ], + ]); + + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Invalid YAML mapping/'); + $flatMapper->createMapping(YamlAuthorDTO::class); + } + + public function testCreateMappingWithInvalidYamlAttributeClassAsserts(): void + { + $flatMapper = new FlatMapper(); + $flatMapper->setYamlMappings([ + YamlAuthorDTO::class => [ + 'properties' => [ + 'id' => ['UnknownAttribute' => 'author_id'], + ], + ], + ]); + + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Attribute class/'); + $flatMapper->createMapping(YamlAuthorDTO::class); + } + + public function testCreateMappingWithYamlClassAttributesSetToNullDoesNotAssert(): void + { + $flatMapper = new FlatMapper(); + // @phpstan-ignore argument.type + $flatMapper->setYamlMappings([ + YamlBookDTO::class => [ + 'class' => null, + 'properties' => [ + 'id' => ['Identifier' => 'book_id'], + ], + ], + ]); + + $this->expectNotToPerformAssertions(); + $flatMapper->createMapping(YamlBookDTO::class); + } + + public function testCreateMappingWithYamlNamedArgumentsDoesNotAssert(): void + { + $flatMapper = new FlatMapper(); + $flatMapper->setYamlMappings([ + YamlBookDTO::class => [ + 'properties' => [ + 'id' => ['Identifier' => ['mappedPropertyName' => 'book_id']], + ], + ], + ]); + + $this->expectNotToPerformAssertions(); + $flatMapper->createMapping(YamlBookDTO::class); + } + + public function testCreateMappingWithYamlFullyQualifiedAttributeNamesDoesNotAssert(): void + { + $flatMapper = new FlatMapper(); + $flatMapper->setYamlMappings([ + YamlBookDTO::class => [ + 'properties' => [ + 'id' => [\Pixelshaped\FlatMapperBundle\Mapping\Identifier::class => 'book_id'], + ], + ], + ]); + + $this->expectNotToPerformAssertions(); + $flatMapper->createMapping(YamlBookDTO::class); + } + + public function testCreateMappingWithYamlAndReflectionAttributesOverlapDoesNotAssert(): void + { + $flatMapper = new FlatMapper(); + $flatMapper->setYamlMappings([ + AuthorDTO::class => [ + 'properties' => [ + 'id' => [ + 'Identifier' => 'yaml_author_id', + 'Scalar' => 'yaml_author_id', + ], + ], + ], + ]); + + $this->expectNotToPerformAssertions(); + $flatMapper->createMapping(AuthorDTO::class); + } + + public function testCreateMappingWithInvalidYamlClassDefinitionAsserts(): void + { + $flatMapper = new FlatMapper(); + // @phpstan-ignore argument.type + $flatMapper->setYamlMappings([ + YamlBookDTO::class => 'invalid', + ]); + + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Expected an array/'); + $flatMapper->createMapping(YamlBookDTO::class); + } + + public function testCreateMappingWithInvalidYamlPropertiesDefinitionAsserts(): void + { + $flatMapper = new FlatMapper(); + // @phpstan-ignore argument.type + $flatMapper->setYamlMappings([ + YamlBookDTO::class => [ + 'properties' => 'invalid', + ], + ]); + + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/The "properties" section must be an array/'); + $flatMapper->createMapping(YamlBookDTO::class); + } + + public function testCreateMappingWithYamlNonStringAttributeNameAsserts(): void + { + $flatMapper = new FlatMapper(); + // @phpstan-ignore argument.type + $flatMapper->setYamlMappings([ + YamlBookDTO::class => [ + 'properties' => [ + 'id' => [0 => 'book_id'], + ], + ], + ]); + + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Attribute names must be strings/'); + $flatMapper->createMapping(YamlBookDTO::class); + } + + public function testCreateMappingWithYamlInvalidAttributeArgumentTypeAsserts(): void + { + $flatMapper = new FlatMapper(); + $flatMapper->setYamlMappings([ + YamlBookDTO::class => [ + 'properties' => [ + 'id' => ['Identifier' => new \stdClass()], + ], + ], + ]); + + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Expected null, scalar, or array arguments/'); + $flatMapper->createMapping(YamlBookDTO::class); + } + + public function testCreateMappingWithYamlReferenceArrayWithoutMappedValueAsserts(): void + { + $flatMapper = new FlatMapper(); + $flatMapper->setYamlMappings([ + YamlAuthorDTO::class => [ + 'properties' => [ + 'id' => ['Identifier' => 'author_id'], + 'books' => ['ReferenceArray' => null], + ], + ], + ]); + + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/requires a mapped value/'); + $flatMapper->createMapping(YamlAuthorDTO::class); + } + + public function testCreateMappingWithYamlInvalidReferenceClassAsserts(): void + { + $flatMapper = new FlatMapper(); + $flatMapper->setYamlMappings([ + YamlAuthorDTO::class => [ + 'properties' => [ + 'id' => ['Identifier' => 'author_id'], + 'books' => ['ReferenceArray' => 'This\\Class\\Does\\NotExist'], + ], + ], + ]); + + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Invalid reference class/'); + $flatMapper->createMapping(YamlAuthorDTO::class); + } + + public function testCreateMappingWithYamlScalarNonStringArgumentAsserts(): void + { + $flatMapper = new FlatMapper(); + $flatMapper->setYamlMappings([ + YamlBookDTO::class => [ + 'properties' => [ + 'id' => ['Identifier' => 'book_id'], + 'name' => ['Scalar' => 123], + ], + ], + ]); + + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Expected string, got int/'); + $flatMapper->createMapping(YamlBookDTO::class); + } + + public function testNormalizeYamlAttributeMapWithNullReturnsEmptyArray(): void + { + $flatMapper = new FlatMapper(); + $reflectionMethod = (new \ReflectionClass(FlatMapper::class))->getMethod('normalizeYamlAttributeMap'); + $reflectionMethod->setAccessible(true); + + /** @var array> $result */ + $result = $reflectionMethod->invoke($flatMapper, null, 'class "Foo\\Bar\\Baz"'); + $this->assertSame([], $result); + } } diff --git a/tests/FlatMapperTest.php b/tests/FlatMapperTest.php index 71e4566..2786ab4 100644 --- a/tests/FlatMapperTest.php +++ b/tests/FlatMapperTest.php @@ -23,6 +23,8 @@ use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ReferenceArray\BookDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ScalarArray\ScalarArrayDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\WithoutAttributeDTO; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Yaml\AuthorDTO as YamlAuthorDTO; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Yaml\BookDTO as YamlBookDTO; #[CoversClass(FlatMapper::class)] #[CoversClass(MappingException::class)] @@ -213,6 +215,81 @@ public function testMapWithoutAttributeDTO(): void ); } + public function testMapWithYamlMappingsOnly(): void + { + $flatMapper = new FlatMapper(); + $flatMapper->setYamlMappings([ + YamlAuthorDTO::class => [ + 'class' => [ + 'NameTransformation' => ['columnPrefix' => 'author_'], + ], + 'properties' => [ + 'id' => ['Identifier' => null], + 'books' => ['ReferenceArray' => YamlBookDTO::class], + ], + ], + YamlBookDTO::class => [ + 'class' => [ + 'NameTransformation' => ['columnPrefix' => 'book_', 'snakeCaseColumns' => true], + ], + 'properties' => [ + 'id' => ['Identifier' => null], + ], + ], + ]); + + $flatMapperResults = $flatMapper->map(YamlAuthorDTO::class, $this->getResultsForNestedDTOs()); + + $bookDto1 = new YamlBookDTO(1, "Travelling as a group", "TravelBooks"); + $bookDto2 = new YamlBookDTO(2, "My journeys", "Lorem Press"); + $bookDto3 = new YamlBookDTO(3, "Coding on the road", "Ipsum Books"); + $bookDto4 = new YamlBookDTO(4, "My best recipes", "Cooking and Stuff"); + + $authorDto1 = new YamlAuthorDTO(1, "Alice Brian", [ + 1 => $bookDto1, 2 => $bookDto2, 3 => $bookDto3 + ]); + $authorDto2 = new YamlAuthorDTO(2, "Bob Schmo", [ + 1 => $bookDto1, 4 => $bookDto4 + ]); + $authorDto5 = new YamlAuthorDTO(5, "Charlie Doe", []); + $handmadeResult = [1 => $authorDto1, 2 => $authorDto2, 5 => $authorDto5]; + + $this->assertSame( + var_export($flatMapperResults, true), + var_export($handmadeResult, true) + ); + } + + public function testMapWithYamlMappingsAndAttributesCombined(): void + { + $results = [ + ['row_id' => 1, 'row_foo' => 'Foo 1', 'row_bar' => 1], + ['row_id' => 2, 'row_foo' => 'Foo 2', 'row_bar' => 2], + ]; + + $flatMapper = new FlatMapper(); + $flatMapper->setYamlMappings([ + WithoutAttributeDTO::class => [ + 'properties' => [ + 'id' => ['Scalar' => 'row_id'], + 'foo' => ['Scalar' => 'row_foo'], + 'bar' => ['Scalar' => 'row_bar'], + ], + ], + ]); + + $flatMapperResults = $flatMapper->map(WithoutAttributeDTO::class, $results); + + $rootDto1 = new WithoutAttributeDTO(1, "Foo 1", 1); + $rootDto2 = new WithoutAttributeDTO(2, "Foo 2", 2); + $handmadeResult = [1 => $rootDto1, 2 => $rootDto2]; + + $this->assertSame( + var_export($flatMapperResults, true), + var_export($handmadeResult, true) + ); + } + public function testMapEmptyData(): void { $flatMapperResults = ((new FlatMapper())->map(ScalarArrayDTO::class, [])); diff --git a/tests/Functional/BundleFunctionalTest.php b/tests/Functional/BundleFunctionalTest.php index f49c527..1e0d5be 100644 --- a/tests/Functional/BundleFunctionalTest.php +++ b/tests/Functional/BundleFunctionalTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Pixelshaped\FlatMapperBundle\FlatMapper; use Pixelshaped\FlatMapperBundle\PixelshapedFlatMapperBundle; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\WithoutAttributeDTO; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\HttpKernel\Kernel; use Symfony\Contracts\Cache\CacheInterface; @@ -33,6 +34,22 @@ public function testServiceWiringWithCacheService(): void $flatMapper = $container->get('pixelshaped_flat_mapper.flat_mapper'); $this->assertInstanceOf(FlatMapper::class, $flatMapper); } + + public function testServiceWiringWithYamlMappings(): void + { + $kernel = new PixelshapedFlatMapperTestingKernelWithMappings('test', true); + $kernel->boot(); + $container = $kernel->getContainer(); + $flatMapper = $container->get('pixelshaped_flat_mapper.flat_mapper'); + + $this->assertInstanceOf(FlatMapper::class, $flatMapper); + + $mapped = $flatMapper->map(WithoutAttributeDTO::class, [ + ['row_id' => 1, 'row_foo' => 'Foo 1', 'row_bar' => 2], + ]); + + $this->assertEquals([1 => new WithoutAttributeDTO(1, 'Foo 1', 2)], $mapped); + } } class PixelshapedFlatMapperTestingKernel extends Kernel @@ -70,6 +87,32 @@ public function registerContainerConfiguration(LoaderInterface $loader): void } } +class PixelshapedFlatMapperTestingKernelWithMappings extends Kernel +{ + public function registerBundles(): iterable + { + return [ + new PixelshapedFlatMapperBundle(), + ]; + } + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(function ($container) { + $container->loadFromExtension('pixelshaped_flat_mapper', [ + 'mappings' => [ + WithoutAttributeDTO::class => [ + 'properties' => [ + 'id' => ['Scalar' => 'row_id'], + 'foo' => ['Scalar' => 'row_foo'], + 'bar' => ['Scalar' => 'row_bar'], + ], + ], + ], + ]); + }); + } +} + class MockCacheAdapter implements CacheInterface { /** diff --git a/tests/PixelshapedFlatMapperBundleTest.php b/tests/PixelshapedFlatMapperBundleTest.php new file mode 100644 index 0000000..75c397b --- /dev/null +++ b/tests/PixelshapedFlatMapperBundleTest.php @@ -0,0 +1,125 @@ + [ + 'properties' => [ + 'id' => ['Identifier' => 'foo_id'], + ], + ], + ]; + + $bundle->loadExtension( + [ + 'cache_service' => 'cache.app', + 'validate_mapping' => false, + 'mappings' => $mappings, + ], + $this->createContainerConfigurator($containerBuilder), + $containerBuilder + ); + + $this->assertTrue($containerBuilder->hasDefinition('pixelshaped_flat_mapper.flat_mapper')); + $definition = $containerBuilder->getDefinition('pixelshaped_flat_mapper.flat_mapper'); + $this->assertSame(FlatMapper::class, $definition->getClass()); + + $callsByMethod = []; + foreach ($definition->getMethodCalls() as [$method, $arguments]) { + $callsByMethod[$method] = $arguments; + } + + $this->assertArrayHasKey('setCacheService', $callsByMethod); + $this->assertArrayHasKey('setValidateMapping', $callsByMethod); + $this->assertArrayHasKey('setYamlMappings', $callsByMethod); + + $this->assertInstanceOf(Reference::class, $callsByMethod['setCacheService'][0]); + $this->assertSame('cache.app', (string)$callsByMethod['setCacheService'][0]); + $this->assertSame([false], $callsByMethod['setValidateMapping']); + $this->assertSame([$mappings], $callsByMethod['setYamlMappings']); + } + + public function testConfigureDefinesExpectedDefaultOptions(): void + { + $bundle = new PixelshapedFlatMapperBundle(); + $treeBuilder = new TreeBuilder('pixelshaped_flat_mapper'); + $definitionLoader = new DefinitionFileLoader( + $treeBuilder, + new FileLocator([dirname(__DIR__)]) + ); + + $bundle->configure(new DefinitionConfigurator( + $treeBuilder, + $definitionLoader, + __FILE__, + __FILE__ + )); + + $processor = new Processor(); + /** @var array{validate_mapping: bool, cache_service: null|string, mappings: array} $processed */ + $processed = $processor->process($treeBuilder->buildTree(), [[]]); + + $this->assertTrue($processed['validate_mapping']); + $this->assertNull($processed['cache_service']); + $this->assertSame([], $processed['mappings']); + } + + public function testLoadExtensionWithNonStringCacheServiceAsserts(): void + { + $bundle = new PixelshapedFlatMapperBundle(); + $containerBuilder = new ContainerBuilder(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "cache_service" option must be a string or null.'); + + $bundle->loadExtension( + [ + 'cache_service' => 123, + 'validate_mapping' => true, + 'mappings' => [], + ], + $this->createContainerConfigurator($containerBuilder), + $containerBuilder + ); + } + + private function createContainerConfigurator(ContainerBuilder $containerBuilder): ContainerConfigurator + { + $instanceof = []; + $projectDirectory = dirname(__DIR__); + $bundlePath = $projectDirectory.'/src/PixelshapedFlatMapperBundle.php'; + + return new ContainerConfigurator( + $containerBuilder, + new PhpFileLoader($containerBuilder, new FileLocator([$projectDirectory])), + $instanceof, + $bundlePath, + $bundlePath, + 'test' + ); + } +} From ff14f9e4704ebef19f1a7dfef5f139de43ce24f7 Mon Sep 17 00:00:00 2001 From: Renaud Date: Sun, 15 Feb 2026 20:25:20 +0100 Subject: [PATCH 02/14] Small refactor --- coverage.xml | 346 --------------------------------------------- src/FlatMapper.php | 39 ++--- 2 files changed, 13 insertions(+), 372 deletions(-) delete mode 100644 coverage.xml diff --git a/coverage.xml b/coverage.xml deleted file mode 100644 index 28bcae1..0000000 --- a/coverage.xml +++ /dev/null @@ -1,346 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/FlatMapper.php b/src/FlatMapper.php index b4354e1..585b1d0 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -114,7 +114,7 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde foreach ($this->getClassMappingAttributes($reflectionClass) as $attribute) { switch ($attribute['name']) { case Identifier::class: - $identifierPropertyName = $this->getStringAttributeArgument( + $identifierPropertyName = $this->getStringMappingArgument( $attribute, 'mappedPropertyName', sprintf('class "%s"', $dtoClassName) @@ -154,7 +154,7 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde $mappingArgumentName = $attribute['name'] === ReferenceArray::class ? 'referenceClassName' : 'mappedPropertyName'; - $mappedProperty = $this->getStringAttributeArgument( + $mappedProperty = $this->getStringMappingArgument( $attribute, $mappingArgumentName, sprintf('property "%s::$%s"', $dtoClassName, $propertyName) @@ -192,7 +192,7 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde } else if ($attribute['name'] === Identifier::class) { $identifiersCount++; $isIdentifier = true; - $identifierColumnName = $this->getStringAttributeArgument( + $identifierColumnName = $this->getStringMappingArgument( $attribute, 'mappedPropertyName', sprintf('property "%s::$%s"', $dtoClassName, $propertyName) @@ -201,7 +201,7 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde $columnName = $identifierColumnName; } } else if ($attribute['name'] === Scalar::class) { - $scalarColumnName = $this->getStringAttributeArgument( + $scalarColumnName = $this->getStringMappingArgument( $attribute, 'mappedPropertyName', sprintf('property "%s::$%s"', $dtoClassName, $propertyName) @@ -223,7 +223,7 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde if($identifiersCount !== 1) { throw new MappingCreationException($dtoClassName.' does not contain exactly one #[Identifier] attribute.'); } - + $uniqueCheck = []; foreach ($objectIdentifiers as $key => $value) { if (isset($uniqueCheck[$value])) { @@ -457,29 +457,16 @@ private function resolveAttributeClassName(string $attributeName, string $mappin * reflectionAttribute?: \ReflectionAttribute * } $attribute */ - private function getAttributeArgument(array $attribute, string $namedArgument, int $position = 0): mixed + private function getStringMappingArgument(array $attribute, string $argumentName, string $mappingTarget): ?string { - if (array_key_exists($position, $attribute['arguments'])) { - return $attribute['arguments'][$position]; - } - - if (array_key_exists($namedArgument, $attribute['arguments'])) { - return $attribute['arguments'][$namedArgument]; + if (array_key_exists(0, $attribute['arguments'])) { + $argument = $attribute['arguments'][0]; + } elseif (array_key_exists($argumentName, $attribute['arguments'])) { + $argument = $attribute['arguments'][$argumentName]; + } else { + $argument = null; } - return null; - } - - /** - * @param array{ - * name: class-string, - * arguments: array, - * reflectionAttribute?: \ReflectionAttribute - * } $attribute - */ - private function getStringAttributeArgument(array $attribute, string $namedArgument, string $mappingTarget, int $position = 0): ?string - { - $argument = $this->getAttributeArgument($attribute, $namedArgument, $position); if ($argument === null) { return null; } @@ -487,7 +474,7 @@ private function getStringAttributeArgument(array $attribute, string $namedArgum if (!is_string($argument)) { throw new MappingCreationException(sprintf( 'Invalid %s argument for attribute "%s" on %s. Expected string, got %s.', - $namedArgument, + $argumentName, $attribute['name'], $mappingTarget, get_debug_type($argument) From 181fade26c78a8f5d750205e24c702bce3fd7df4 Mon Sep 17 00:00:00 2001 From: Renaud Date: Sun, 15 Feb 2026 22:29:10 +0100 Subject: [PATCH 03/14] Add a MappingResolver to separate concerns --- src/FlatMapper.php | 517 ++-------------------- src/MappingResolver.php | 470 ++++++++++++++++++++ tests/FlatMapperCreateMappingTest.php | 24 +- tests/FlatMapperTest.php | 2 + tests/Functional/BundleFunctionalTest.php | 2 + 5 files changed, 536 insertions(+), 479 deletions(-) create mode 100644 src/MappingResolver.php diff --git a/src/FlatMapper.php b/src/FlatMapper.php index 585b1d0..c7cafcb 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -3,44 +3,28 @@ namespace Pixelshaped\FlatMapperBundle; -use Error; use Pixelshaped\FlatMapperBundle\Exception\MappingCreationException; use Pixelshaped\FlatMapperBundle\Exception\MappingException; -use Pixelshaped\FlatMapperBundle\Mapping\Identifier; -use Pixelshaped\FlatMapperBundle\Mapping\NameTransformation; -use Pixelshaped\FlatMapperBundle\Mapping\ReferenceArray; -use Pixelshaped\FlatMapperBundle\Mapping\Scalar; -use Pixelshaped\FlatMapperBundle\Mapping\ScalarArray; -use ReflectionClass; use ReflectionProperty; use Symfony\Contracts\Cache\CacheInterface; final class FlatMapper { - // Pre-compiled regex patterns for better performance - private const SNAKE_CASE_PATTERN_1 = '/([A-Z]+)([A-Z][a-z])/'; - private const SNAKE_CASE_PATTERN_2 = '/([a-z\d])([A-Z])/'; - private const SNAKE_CASE_REPLACEMENT = '\1_\2'; - private const MAPPING_NAMESPACE_PREFIX = 'Pixelshaped\\FlatMapperBundle\\Mapping\\'; - - /** - * @var array> - */ - private array $objectIdentifiers = []; - /** - * @var array>> - */ - private array $objectsMapping = []; /** * @var array, - * properties?: array> + * objectIdentifiers: array, + * objectsMapping: array> * }> */ - private array $yamlMappings = []; + private array $mappings = []; private ?CacheInterface $cacheService = null; - private bool $validateMapping = true; + private MappingResolver $mappingResolver; + + public function __construct() + { + $this->mappingResolver = new MappingResolver(); + } public function setCacheService(CacheInterface $cacheService): void { @@ -49,7 +33,7 @@ public function setCacheService(CacheInterface $cacheService): void public function setValidateMapping(bool $validateMapping): void { - $this->validateMapping = $validateMapping; + $this->mappingResolver->setValidateMapping($validateMapping); } /** @@ -60,445 +44,8 @@ public function setValidateMapping(bool $validateMapping): void */ public function setYamlMappings(array $yamlMappings): void { - $this->yamlMappings = $yamlMappings; - - // Mapping source changed, so in-memory compiled mappings need to be rebuilt. - $this->objectIdentifiers = []; - $this->objectsMapping = []; - } - - public function createMapping(string $dtoClassName): void - { - if(!class_exists($dtoClassName)) { - throw new MappingCreationException($dtoClassName.' is not a valid class name'); - } - if(!isset($this->objectsMapping[$dtoClassName])) { - - if($this->cacheService !== null) { - $mappingInfo = $this->cacheService->get($this->createCacheKey($dtoClassName), function () use ($dtoClassName): array { - return $this->createMappingRecursive($dtoClassName); - }); - } else { - $mappingInfo = $this->createMappingRecursive($dtoClassName); - } - - $this->objectsMapping[$dtoClassName] = $mappingInfo['objectsMapping']; - $this->objectIdentifiers[$dtoClassName] = $mappingInfo['objectIdentifiers']; - } - } - - /** - * @param class-string $dtoClassName - * @param array|null $objectIdentifiers - * @param array>|null $objectsMapping - * @return array{'objectIdentifiers': array, "objectsMapping": array>} - */ - private function createMappingRecursive(string $dtoClassName, ?array& $objectIdentifiers = null, ?array& $objectsMapping = null): array - { - if($objectIdentifiers === null) $objectIdentifiers = []; - if($objectsMapping === null) $objectsMapping = []; - - $objectIdentifiers = array_merge([$dtoClassName => 'RESERVED'], $objectIdentifiers); - - $reflectionClass = new ReflectionClass($dtoClassName); - - $constructor = $reflectionClass->getConstructor(); - - if($constructor === null) { - throw new MappingCreationException('Class "' . $dtoClassName . '" does not have a constructor.'); - } - - $identifiersCount = 0; - $transformation = null; - - foreach ($this->getClassMappingAttributes($reflectionClass) as $attribute) { - switch ($attribute['name']) { - case Identifier::class: - $identifierPropertyName = $this->getStringMappingArgument( - $attribute, - 'mappedPropertyName', - sprintf('class "%s"', $dtoClassName) - ); - if ($identifierPropertyName !== null) { - $objectIdentifiers[$dtoClassName] = $identifierPropertyName; - $identifiersCount++; - } else { - throw new MappingCreationException('The Identifier attribute cannot be used without a property name when used as a Class attribute'); - } - break; - - case NameTransformation::class: - try { - /** @var NameTransformation $transformationInstance */ - $transformationInstance = $this->newMappingAttributeInstance($attribute); - $transformation = $transformationInstance; - } catch (Error $e) { - throw new MappingCreationException(sprintf( - 'Invalid NameTransformation attribute for %s:%s%s', - $dtoClassName, - PHP_EOL, - $e->getMessage() - )); - } - } - } - - foreach ($constructor->getParameters() as $reflectionParameter) { - $propertyName = $reflectionParameter->getName(); - $columnName = $transformation - ? $this->transformPropertyName($propertyName, $transformation) - : $propertyName; - $isIdentifier = false; - foreach ($this->getPropertyMappingAttributes($dtoClassName, $propertyName, $reflectionParameter->getAttributes()) as $attribute) { - if ($attribute['name'] === ReferenceArray::class || $attribute['name'] === ScalarArray::class) { - $mappingArgumentName = $attribute['name'] === ReferenceArray::class - ? 'referenceClassName' - : 'mappedPropertyName'; - $mappedProperty = $this->getStringMappingArgument( - $attribute, - $mappingArgumentName, - sprintf('property "%s::$%s"', $dtoClassName, $propertyName) - ); - - if($mappedProperty === null) { - throw new MappingCreationException(sprintf( - 'Attribute "%s" on property "%s::$%s" requires a mapped value.', - $attribute['name'], - $dtoClassName, - $propertyName - )); - } - - if($this->validateMapping) { - if((new ReflectionProperty($dtoClassName, $propertyName))->isReadOnly()) { - throw new MappingCreationException($reflectionClass->getName().': property '.$propertyName.' cannot be readonly as it is non-scalar and '.static::class.' needs to access it after object instantiation.'); - } - } - $objectsMapping[$dtoClassName][$propertyName] = $mappedProperty; - if($attribute['name'] === ReferenceArray::class) { - if(!class_exists($mappedProperty)) { - throw new MappingCreationException(sprintf( - 'Invalid reference class "%s" configured on property "%s::$%s".', - $mappedProperty, - $dtoClassName, - $propertyName - )); - } - - /** @var class-string $mappedProperty */ - $this->createMappingRecursive($mappedProperty, $objectIdentifiers, $objectsMapping); - } - continue 2; - } else if ($attribute['name'] === Identifier::class) { - $identifiersCount++; - $isIdentifier = true; - $identifierColumnName = $this->getStringMappingArgument( - $attribute, - 'mappedPropertyName', - sprintf('property "%s::$%s"', $dtoClassName, $propertyName) - ); - if($identifierColumnName !== null) { - $columnName = $identifierColumnName; - } - } else if ($attribute['name'] === Scalar::class) { - $scalarColumnName = $this->getStringMappingArgument( - $attribute, - 'mappedPropertyName', - sprintf('property "%s::$%s"', $dtoClassName, $propertyName) - ); - if($scalarColumnName !== null) { - $columnName = $scalarColumnName; - } - } - } - - if ($isIdentifier) { - $objectIdentifiers[$dtoClassName] = $columnName; - } - - $objectsMapping[$dtoClassName][$columnName] = null; - } - - if($this->validateMapping) { - if($identifiersCount !== 1) { - throw new MappingCreationException($dtoClassName.' does not contain exactly one #[Identifier] attribute.'); - } - - $uniqueCheck = []; - foreach ($objectIdentifiers as $key => $value) { - if (isset($uniqueCheck[$value])) { - throw new MappingCreationException('Several data identifiers are identical: ' . print_r($objectIdentifiers, true)); - } - $uniqueCheck[$value] = true; - } - } - - return [ - 'objectIdentifiers' => $objectIdentifiers, - 'objectsMapping' => $objectsMapping - ]; - } - - private function transformPropertyName(string $propertyName, NameTransformation $transformation): string - { - if ($transformation->snakeCaseColumns) { - $propertyName = strtolower(preg_replace( - [self::SNAKE_CASE_PATTERN_1, self::SNAKE_CASE_PATTERN_2], - self::SNAKE_CASE_REPLACEMENT, - $propertyName - ) ?? $propertyName); - } - return $transformation->columnPrefix . $propertyName; - } - - /** - * Keep cache keys deterministic while invalidating when YAML mapping changes. - */ - private function createCacheKey(string $dtoClassName): string - { - $cacheKey = strtr($dtoClassName, ['\\' => '_', '-' => '_', ' ' => '_']); - $mappingHash = md5(serialize($this->yamlMappings[$dtoClassName] ?? [])); - - return 'pixelshaped_flat_mapper_'.$cacheKey.'_'.$mappingHash; - } - - /** - * @param ReflectionClass $reflectionClass - * @return list, - * reflectionAttribute?: \ReflectionAttribute - * }> - */ - private function getClassMappingAttributes(ReflectionClass $reflectionClass): array - { - return $this->mergeMappingAttributes( - $reflectionClass->getName(), - $reflectionClass->getAttributes() - ); - } - - /** - * @param list<\ReflectionAttribute> $reflectionAttributes - * @return list, - * reflectionAttribute?: \ReflectionAttribute - * }> - */ - private function getPropertyMappingAttributes(string $dtoClassName, string $propertyName, array $reflectionAttributes): array - { - return $this->mergeMappingAttributes( - $dtoClassName, - $reflectionAttributes, - $propertyName - ); - } - - /** - * @param list<\ReflectionAttribute> $reflectionAttributes - * @return list, - * reflectionAttribute?: \ReflectionAttribute - * }> - */ - private function mergeMappingAttributes(string $dtoClassName, array $reflectionAttributes, ?string $propertyName = null): array - { - $mappingAttributes = []; - - foreach ($this->getYamlAttributes($dtoClassName, $propertyName) as $attributeName => $attributeArguments) { - $mappingAttributes[$attributeName] = [ - 'name' => $attributeName, - 'arguments' => $attributeArguments, - ]; - } - - foreach ($reflectionAttributes as $reflectionAttribute) { - $attributeName = $reflectionAttribute->getName(); - if (isset($mappingAttributes[$attributeName])) { - // Ensure reflection attributes override YAML and keep declaration ordering. - unset($mappingAttributes[$attributeName]); - } - $mappingAttributes[$attributeName] = [ - 'name' => $attributeName, - 'arguments' => $reflectionAttribute->getArguments(), - 'reflectionAttribute' => $reflectionAttribute, - ]; - } - - return array_values($mappingAttributes); - } - - /** - * @return array> - */ - private function getYamlAttributes(string $dtoClassName, ?string $propertyName = null): array - { - if (!isset($this->yamlMappings[$dtoClassName])) { - return []; - } - - $classMapping = $this->yamlMappings[$dtoClassName]; - if (!is_array($classMapping)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for class "%s". Expected an array.', - $dtoClassName - )); - } - - if ($propertyName === null) { - $rawAttributes = $classMapping['class'] ?? []; - return $this->normalizeYamlAttributeMap( - $rawAttributes, - sprintf('class "%s"', $dtoClassName) - ); - } - - $rawProperties = $classMapping['properties'] ?? []; - if (!is_array($rawProperties)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for class "%s". The "properties" section must be an array.', - $dtoClassName - )); - } - - $rawAttributes = $rawProperties[$propertyName] ?? []; - return $this->normalizeYamlAttributeMap( - $rawAttributes, - sprintf('property "%s::$%s"', $dtoClassName, $propertyName) - ); - } - - /** - * @return array> - */ - private function normalizeYamlAttributeMap(mixed $rawAttributes, string $mappingTarget): array - { - if ($rawAttributes === null) { - return []; - } - - if (!is_array($rawAttributes)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for %s. Expected an attribute map array.', - $mappingTarget - )); - } - - $normalizedAttributes = []; - foreach ($rawAttributes as $attributeName => $rawArguments) { - if (!is_string($attributeName)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for %s. Attribute names must be strings.', - $mappingTarget - )); - } - - $resolvedAttributeName = $this->resolveAttributeClassName($attributeName, $mappingTarget); - $normalizedAttributes[$resolvedAttributeName] = $this->normalizeYamlAttributeArguments( - $rawArguments, - $attributeName, - $mappingTarget - ); - } - - return $normalizedAttributes; - } - - /** - * @return array - */ - private function normalizeYamlAttributeArguments(mixed $rawArguments, string $attributeName, string $mappingTarget): array - { - if ($rawArguments === null) { - return []; - } - - if (is_scalar($rawArguments)) { - return [$rawArguments]; - } - - if (is_array($rawArguments)) { - return $rawArguments; - } - - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for attribute "%s" on %s. Expected null, scalar, or array arguments.', - $attributeName, - $mappingTarget - )); - } - - /** - * @return class-string - */ - private function resolveAttributeClassName(string $attributeName, string $mappingTarget): string - { - $className = str_contains($attributeName, '\\') - ? $attributeName - : self::MAPPING_NAMESPACE_PREFIX.$attributeName; - - if (!class_exists($className)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for %s. Attribute class "%s" does not exist.', - $mappingTarget, - $className - )); - } - - return $className; - } - - /** - * @param array{ - * name: class-string, - * arguments: array, - * reflectionAttribute?: \ReflectionAttribute - * } $attribute - */ - private function getStringMappingArgument(array $attribute, string $argumentName, string $mappingTarget): ?string - { - if (array_key_exists(0, $attribute['arguments'])) { - $argument = $attribute['arguments'][0]; - } elseif (array_key_exists($argumentName, $attribute['arguments'])) { - $argument = $attribute['arguments'][$argumentName]; - } else { - $argument = null; - } - - if ($argument === null) { - return null; - } - - if (!is_string($argument)) { - throw new MappingCreationException(sprintf( - 'Invalid %s argument for attribute "%s" on %s. Expected string, got %s.', - $argumentName, - $attribute['name'], - $mappingTarget, - get_debug_type($argument) - )); - } - - return $argument; - } - - /** - * @param array{ - * name: class-string, - * arguments: array, - * reflectionAttribute?: \ReflectionAttribute - * } $attribute - */ - private function newMappingAttributeInstance(array $attribute): object - { - if (isset($attribute['reflectionAttribute'])) { - return $attribute['reflectionAttribute']->newInstance(); - } - - $attributeClassName = $attribute['name']; - return new $attributeClassName(...$attribute['arguments']); + $this->mappingResolver->setYamlMappings($yamlMappings); + $this->mappings = []; } /** @@ -510,21 +57,24 @@ private function newMappingAttributeInstance(array $attribute): object public function map(string $dtoClassName, iterable $data): array { $this->createMapping($dtoClassName); + $mapping = $this->mappings[$dtoClassName]; + $objectIdentifiers = $mapping['objectIdentifiers']; + $objectsMapping = $mapping['objectsMapping']; $objectsMap = []; $referencesMap = []; foreach ($data as $row) { - foreach ($this->objectIdentifiers[$dtoClassName] as $objectClass => $identifier) { + foreach ($objectIdentifiers as $objectClass => $identifier) { if (!array_key_exists($identifier, $row)) { throw new MappingException('Identifier not found: ' . $identifier); } if ($row[$identifier] !== null && !isset($objectsMap[$identifier][$row[$identifier]])) { $constructorValues = []; - foreach ($this->objectsMapping[$dtoClassName][$objectClass] as $objectProperty => $foreignObjectClassOrIdentifier) { + foreach ($objectsMapping[$objectClass] as $objectProperty => $foreignObjectClassOrIdentifier) { if($foreignObjectClassOrIdentifier !== null) { - if (isset($this->objectsMapping[$dtoClassName][$foreignObjectClassOrIdentifier])) { + if (isset($objectsMapping[$foreignObjectClassOrIdentifier])) { // Handles ReferenceArray attribute - $foreignIdentifier = $this->objectIdentifiers[$dtoClassName][$foreignObjectClassOrIdentifier]; + $foreignIdentifier = $objectIdentifiers[$foreignObjectClassOrIdentifier]; if($row[$foreignIdentifier] !== null) { $referencesMap[$objectClass][$row[$identifier]][$objectProperty][$row[$foreignIdentifier]] = $objectsMap[$foreignObjectClassOrIdentifier][$row[$foreignIdentifier]]; } @@ -558,6 +108,24 @@ public function map(string $dtoClassName, iterable $data): array { return $rootObjects; } + public function createMapping(string $dtoClassName): void + { + if(!class_exists($dtoClassName)) { + throw new MappingCreationException($dtoClassName.' is not a valid class name'); + } + if(!isset($this->mappings[$dtoClassName])) { + if($this->cacheService !== null) { + $mappingInfo = $this->cacheService->get($this->createCacheKey($dtoClassName), function () use ($dtoClassName): array { + return $this->mappingResolver->resolve($dtoClassName); + }); + } else { + $mappingInfo = $this->mappingResolver->resolve($dtoClassName); + } + + $this->mappings[$dtoClassName] = $mappingInfo; + } + } + /** * @template T of object * @param array, array>> $referencesMap @@ -576,4 +144,15 @@ private function linkObjects(array $referencesMap, array $objectsMap): void } } } + + /** + * Keep cache keys deterministic while invalidating when YAML mapping changes. + */ + private function createCacheKey(string $dtoClassName): string + { + $cacheKey = strtr($dtoClassName, ['\\' => '_', '-' => '_', ' ' => '_']); + $mappingHash = $this->mappingResolver->getCacheSignature($dtoClassName); + + return 'pixelshaped_flat_mapper_'.$cacheKey.'_'.$mappingHash; + } } diff --git a/src/MappingResolver.php b/src/MappingResolver.php new file mode 100644 index 0000000..ec45f7f --- /dev/null +++ b/src/MappingResolver.php @@ -0,0 +1,470 @@ +, + * properties?: array> + * }> + */ + private array $yamlMappings = []; + + public function setValidateMapping(bool $validateMapping): void + { + $this->validateMapping = $validateMapping; + } + + /** + * @param array, + * properties?: array> + * }> $yamlMappings + */ + public function setYamlMappings(array $yamlMappings): void + { + $this->yamlMappings = $yamlMappings; + } + + public function getCacheSignature(string $dtoClassName): string + { + return md5(serialize($this->yamlMappings[$dtoClassName] ?? [])); + } + + /** + * @param class-string $dtoClassName + * @return array{'objectIdentifiers': array, "objectsMapping": array>} + */ + public function resolve(string $dtoClassName): array + { + if(!class_exists($dtoClassName)) { + throw new MappingCreationException($dtoClassName.' is not a valid class name'); + } + + return $this->createMappingRecursive($dtoClassName); + } + + /** + * @param class-string $dtoClassName + * @param array|null $objectIdentifiers + * @param array>|null $objectsMapping + * @return array{'objectIdentifiers': array, "objectsMapping": array>} + */ + private function createMappingRecursive(string $dtoClassName, ?array& $objectIdentifiers = null, ?array& $objectsMapping = null): array + { + if($objectIdentifiers === null) $objectIdentifiers = []; + if($objectsMapping === null) $objectsMapping = []; + + $objectIdentifiers = array_merge([$dtoClassName => 'RESERVED'], $objectIdentifiers); + + $reflectionClass = new ReflectionClass($dtoClassName); + + $constructor = $reflectionClass->getConstructor(); + + if($constructor === null) { + throw new MappingCreationException('Class "' . $dtoClassName . '" does not have a constructor.'); + } + + $identifiersCount = 0; + $transformation = null; + + foreach ($this->getClassMappingAttributes($reflectionClass) as $attribute) { + switch ($attribute['name']) { + case Identifier::class: + $identifierPropertyName = $this->getStringMappingArgument( + $attribute, + 'mappedPropertyName', + sprintf('class "%s"', $dtoClassName) + ); + if ($identifierPropertyName !== null) { + $objectIdentifiers[$dtoClassName] = $identifierPropertyName; + $identifiersCount++; + } else { + throw new MappingCreationException('The Identifier attribute cannot be used without a property name when used as a Class attribute'); + } + break; + + case NameTransformation::class: + try { + /** @var NameTransformation $transformationInstance */ + $transformationInstance = $this->newMappingAttributeInstance($attribute); + $transformation = $transformationInstance; + } catch (Error $e) { + throw new MappingCreationException(sprintf( + 'Invalid NameTransformation attribute for %s:%s%s', + $dtoClassName, + PHP_EOL, + $e->getMessage() + )); + } + } + } + + foreach ($constructor->getParameters() as $reflectionParameter) { + $propertyName = $reflectionParameter->getName(); + $columnName = $transformation + ? $this->transformPropertyName($propertyName, $transformation) + : $propertyName; + $isIdentifier = false; + foreach ($this->getPropertyMappingAttributes($dtoClassName, $propertyName, $reflectionParameter->getAttributes()) as $attribute) { + if ($attribute['name'] === ReferenceArray::class || $attribute['name'] === ScalarArray::class) { + $mappingArgumentName = $attribute['name'] === ReferenceArray::class + ? 'referenceClassName' + : 'mappedPropertyName'; + $mappedProperty = $this->getStringMappingArgument( + $attribute, + $mappingArgumentName, + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + + if($mappedProperty === null) { + throw new MappingCreationException(sprintf( + 'Attribute "%s" on property "%s::$%s" requires a mapped value.', + $attribute['name'], + $dtoClassName, + $propertyName + )); + } + + if($this->validateMapping) { + if((new ReflectionProperty($dtoClassName, $propertyName))->isReadOnly()) { + throw new MappingCreationException($reflectionClass->getName().': property '.$propertyName.' cannot be readonly as it is non-scalar and '.FlatMapper::class.' needs to access it after object instantiation.'); + } + } + $objectsMapping[$dtoClassName][$propertyName] = $mappedProperty; + if($attribute['name'] === ReferenceArray::class) { + if(!class_exists($mappedProperty)) { + throw new MappingCreationException(sprintf( + 'Invalid reference class "%s" configured on property "%s::$%s".', + $mappedProperty, + $dtoClassName, + $propertyName + )); + } + + /** @var class-string $mappedProperty */ + $this->createMappingRecursive($mappedProperty, $objectIdentifiers, $objectsMapping); + } + continue 2; + } else if ($attribute['name'] === Identifier::class) { + $identifiersCount++; + $isIdentifier = true; + $identifierColumnName = $this->getStringMappingArgument( + $attribute, + 'mappedPropertyName', + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + if($identifierColumnName !== null) { + $columnName = $identifierColumnName; + } + } else if ($attribute['name'] === Scalar::class) { + $scalarColumnName = $this->getStringMappingArgument( + $attribute, + 'mappedPropertyName', + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + if($scalarColumnName !== null) { + $columnName = $scalarColumnName; + } + } + } + + if ($isIdentifier) { + $objectIdentifiers[$dtoClassName] = $columnName; + } + + $objectsMapping[$dtoClassName][$columnName] = null; + } + + if($this->validateMapping) { + if($identifiersCount !== 1) { + throw new MappingCreationException($dtoClassName.' does not contain exactly one #[Identifier] attribute.'); + } + + $uniqueCheck = []; + foreach ($objectIdentifiers as $value) { + if (isset($uniqueCheck[$value])) { + throw new MappingCreationException('Several data identifiers are identical: ' . print_r($objectIdentifiers, true)); + } + $uniqueCheck[$value] = true; + } + } + + return [ + 'objectIdentifiers' => $objectIdentifiers, + 'objectsMapping' => $objectsMapping + ]; + } + + private function transformPropertyName(string $propertyName, NameTransformation $transformation): string + { + if ($transformation->snakeCaseColumns) { + $propertyName = strtolower(preg_replace( + [self::SNAKE_CASE_PATTERN_1, self::SNAKE_CASE_PATTERN_2], + self::SNAKE_CASE_REPLACEMENT, + $propertyName + ) ?? $propertyName); + } + return $transformation->columnPrefix . $propertyName; + } + + /** + * @param ReflectionClass $reflectionClass + * @return list, + * reflectionAttribute?: \ReflectionAttribute + * }> + */ + private function getClassMappingAttributes(ReflectionClass $reflectionClass): array + { + return $this->mergeMappingAttributes( + $reflectionClass->getName(), + $reflectionClass->getAttributes() + ); + } + + /** + * @param list<\ReflectionAttribute> $reflectionAttributes + * @return list, + * reflectionAttribute?: \ReflectionAttribute + * }> + */ + private function getPropertyMappingAttributes(string $dtoClassName, string $propertyName, array $reflectionAttributes): array + { + return $this->mergeMappingAttributes( + $dtoClassName, + $reflectionAttributes, + $propertyName + ); + } + + /** + * @param list<\ReflectionAttribute> $reflectionAttributes + * @return list, + * reflectionAttribute?: \ReflectionAttribute + * }> + */ + private function mergeMappingAttributes(string $dtoClassName, array $reflectionAttributes, ?string $propertyName = null): array + { + $mappingAttributes = []; + + foreach ($this->getYamlAttributes($dtoClassName, $propertyName) as $attributeName => $attributeArguments) { + $mappingAttributes[$attributeName] = [ + 'name' => $attributeName, + 'arguments' => $attributeArguments, + ]; + } + + foreach ($reflectionAttributes as $reflectionAttribute) { + $attributeName = $reflectionAttribute->getName(); + if (isset($mappingAttributes[$attributeName])) { + // Ensure reflection attributes override YAML and keep declaration ordering. + unset($mappingAttributes[$attributeName]); + } + $mappingAttributes[$attributeName] = [ + 'name' => $attributeName, + 'arguments' => $reflectionAttribute->getArguments(), + 'reflectionAttribute' => $reflectionAttribute, + ]; + } + + return array_values($mappingAttributes); + } + + /** + * @return array> + */ + private function getYamlAttributes(string $dtoClassName, ?string $propertyName = null): array + { + if (!isset($this->yamlMappings[$dtoClassName])) { + return []; + } + + $classMapping = $this->yamlMappings[$dtoClassName]; + if (!is_array($classMapping)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for class "%s". Expected an array.', + $dtoClassName + )); + } + + if ($propertyName === null) { + $rawAttributes = $classMapping['class'] ?? []; + return $this->normalizeYamlAttributeMap( + $rawAttributes, + sprintf('class "%s"', $dtoClassName) + ); + } + + $rawProperties = $classMapping['properties'] ?? []; + if (!is_array($rawProperties)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for class "%s". The "properties" section must be an array.', + $dtoClassName + )); + } + + $rawAttributes = $rawProperties[$propertyName] ?? []; + return $this->normalizeYamlAttributeMap( + $rawAttributes, + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + } + + /** + * @return array> + */ + private function normalizeYamlAttributeMap(mixed $rawAttributes, string $mappingTarget): array + { + if ($rawAttributes === null) { + return []; + } + + if (!is_array($rawAttributes)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Expected an attribute map array.', + $mappingTarget + )); + } + + $normalizedAttributes = []; + foreach ($rawAttributes as $attributeName => $rawArguments) { + if (!is_string($attributeName)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Attribute names must be strings.', + $mappingTarget + )); + } + + $resolvedAttributeName = $this->resolveAttributeClassName($attributeName, $mappingTarget); + $normalizedAttributes[$resolvedAttributeName] = $this->normalizeYamlAttributeArguments( + $rawArguments, + $attributeName, + $mappingTarget + ); + } + + return $normalizedAttributes; + } + + /** + * @return array + */ + private function normalizeYamlAttributeArguments(mixed $rawArguments, string $attributeName, string $mappingTarget): array + { + if ($rawArguments === null) { + return []; + } + + if (is_scalar($rawArguments)) { + return [$rawArguments]; + } + + if (is_array($rawArguments)) { + return $rawArguments; + } + + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for attribute "%s" on %s. Expected null, scalar, or array arguments.', + $attributeName, + $mappingTarget + )); + } + + /** + * @return class-string + */ + private function resolveAttributeClassName(string $attributeName, string $mappingTarget): string + { + $className = str_contains($attributeName, '\\') + ? $attributeName + : self::MAPPING_NAMESPACE_PREFIX.$attributeName; + + if (!class_exists($className)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Attribute class "%s" does not exist.', + $mappingTarget, + $className + )); + } + + return $className; + } + + /** + * @param array{ + * name: class-string, + * arguments: array, + * reflectionAttribute?: \ReflectionAttribute + * } $attribute + */ + private function getStringMappingArgument(array $attribute, string $argumentName, string $mappingTarget): ?string + { + if (array_key_exists(0, $attribute['arguments'])) { + $argument = $attribute['arguments'][0]; + } elseif (array_key_exists($argumentName, $attribute['arguments'])) { + $argument = $attribute['arguments'][$argumentName]; + } else { + $argument = null; + } + + if ($argument === null) { + return null; + } + + if (!is_string($argument)) { + throw new MappingCreationException(sprintf( + 'Invalid %s argument for attribute "%s" on %s. Expected string, got %s.', + $argumentName, + $attribute['name'], + $mappingTarget, + get_debug_type($argument) + )); + } + + return $argument; + } + + /** + * @param array{ + * name: class-string, + * arguments: array, + * reflectionAttribute?: \ReflectionAttribute + * } $attribute + */ + private function newMappingAttributeInstance(array $attribute): object + { + if (isset($attribute['reflectionAttribute'])) { + return $attribute['reflectionAttribute']->newInstance(); + } + + $attributeClassName = $attribute['name']; + return new $attributeClassName(...$attribute['arguments']); + } +} diff --git a/tests/FlatMapperCreateMappingTest.php b/tests/FlatMapperCreateMappingTest.php index 28aaae7..37b0de5 100644 --- a/tests/FlatMapperCreateMappingTest.php +++ b/tests/FlatMapperCreateMappingTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use Pixelshaped\FlatMapperBundle\Exception\MappingCreationException; use Pixelshaped\FlatMapperBundle\FlatMapper; +use Pixelshaped\FlatMapperBundle\MappingResolver; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\NameTransformation\InvalidNameTransformationDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTO as InvalidRootDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithEmptyClassIdentifier; @@ -23,10 +24,10 @@ use Symfony\Contracts\Cache\CacheInterface; #[CoversMethod(FlatMapper::class, 'createMapping')] -#[CoversMethod(FlatMapper::class, 'createMappingRecursive')] #[CoversMethod(FlatMapper::class, 'setCacheService')] #[CoversMethod(FlatMapper::class, 'setYamlMappings')] #[CoversClass(FlatMapper::class)] +#[CoversClass(MappingResolver::class)] #[CoversClass(MappingCreationException::class)] class FlatMapperCreateMappingTest extends TestCase { @@ -40,14 +41,9 @@ public function testCreateMappingWithValidDTOsDoesNotAssert(): void public function testCreateMappingWithCacheServiceDoesNotAssert(): void { $flatMapper = new FlatMapper(); - - // The intention is not to test the createMappingRecursive private method - // but to dynamically give the CacheInterface mock a proper return value. - $reflectionMethod = (new \ReflectionClass(FlatMapper::class))->getMethod('createMappingRecursive'); - $reflectionMethod->setAccessible(true); $cacheInterface = $this->createMock(CacheInterface::class); $cacheInterface->expects($this->once())->method('get')->willReturn( - $reflectionMethod->invoke($flatMapper, AuthorDTO::class) + (new MappingResolver())->resolve(AuthorDTO::class) ); $flatMapper->setCacheService($cacheInterface); @@ -76,6 +72,14 @@ public function testCreateMappingWrongClassNameAsserts(): void (new FlatMapper())->createMapping('ThisIsNotAValidClassString'); } + public function testResolveWrongClassNameAsserts(): void + { + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches("/An error occurred during mapping creation: ThisIsNotAValidClassString is not a valid class name/"); + // @phpstan-ignore argument.type + (new MappingResolver())->resolve('ThisIsNotAValidClassString'); + } + public function testCreateMappingWithSeveralIdenticalIdentifiersAsserts(): void { $this->expectException(MappingCreationException::class); @@ -370,12 +374,12 @@ public function testCreateMappingWithYamlScalarNonStringArgumentAsserts(): void public function testNormalizeYamlAttributeMapWithNullReturnsEmptyArray(): void { - $flatMapper = new FlatMapper(); - $reflectionMethod = (new \ReflectionClass(FlatMapper::class))->getMethod('normalizeYamlAttributeMap'); + $mappingResolver = new MappingResolver(); + $reflectionMethod = (new \ReflectionClass(MappingResolver::class))->getMethod('normalizeYamlAttributeMap'); $reflectionMethod->setAccessible(true); /** @var array> $result */ - $result = $reflectionMethod->invoke($flatMapper, null, 'class "Foo\\Bar\\Baz"'); + $result = $reflectionMethod->invoke($mappingResolver, null, 'class "Foo\\Bar\\Baz"'); $this->assertSame([], $result); } } diff --git a/tests/FlatMapperTest.php b/tests/FlatMapperTest.php index 2786ab4..6a7cfd2 100644 --- a/tests/FlatMapperTest.php +++ b/tests/FlatMapperTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Pixelshaped\FlatMapperBundle\Exception\MappingException; use Pixelshaped\FlatMapperBundle\FlatMapper; +use Pixelshaped\FlatMapperBundle\MappingResolver; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ClassAttributes\AuthorDTO as ClassAttributesAuthorDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ClassAttributes\BookDTO as ClassAttributesBookDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Complex\CustomerDTO; @@ -27,6 +28,7 @@ use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Yaml\BookDTO as YamlBookDTO; #[CoversClass(FlatMapper::class)] +#[CoversClass(MappingResolver::class)] #[CoversClass(MappingException::class)] class FlatMapperTest extends TestCase { diff --git a/tests/Functional/BundleFunctionalTest.php b/tests/Functional/BundleFunctionalTest.php index 1e0d5be..e7fe5b5 100644 --- a/tests/Functional/BundleFunctionalTest.php +++ b/tests/Functional/BundleFunctionalTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Pixelshaped\FlatMapperBundle\FlatMapper; +use Pixelshaped\FlatMapperBundle\MappingResolver; use Pixelshaped\FlatMapperBundle\PixelshapedFlatMapperBundle; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\WithoutAttributeDTO; use Symfony\Component\Config\Loader\LoaderInterface; @@ -14,6 +15,7 @@ use Symfony\Contracts\Cache\ItemInterface; #[CoversClass(FlatMapper::class)] +#[CoversClass(MappingResolver::class)] #[CoversClass(PixelshapedFlatMapperBundle::class)] class BundleFunctionalTest extends TestCase { From 9e6ad0de8b065c637d60cee8ceaacecf4918826d Mon Sep 17 00:00:00 2001 From: Renaud Date: Mon, 16 Feb 2026 09:47:09 +0100 Subject: [PATCH 04/14] Revert "Add a MappingResolver to separate concerns" This reverts commit 181fade26c78a8f5d750205e24c702bce3fd7df4. --- src/FlatMapper.php | 517 ++++++++++++++++++++-- src/MappingResolver.php | 470 -------------------- tests/FlatMapperCreateMappingTest.php | 24 +- tests/FlatMapperTest.php | 2 - tests/Functional/BundleFunctionalTest.php | 2 - 5 files changed, 479 insertions(+), 536 deletions(-) delete mode 100644 src/MappingResolver.php diff --git a/src/FlatMapper.php b/src/FlatMapper.php index c7cafcb..585b1d0 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -3,28 +3,44 @@ namespace Pixelshaped\FlatMapperBundle; +use Error; use Pixelshaped\FlatMapperBundle\Exception\MappingCreationException; use Pixelshaped\FlatMapperBundle\Exception\MappingException; +use Pixelshaped\FlatMapperBundle\Mapping\Identifier; +use Pixelshaped\FlatMapperBundle\Mapping\NameTransformation; +use Pixelshaped\FlatMapperBundle\Mapping\ReferenceArray; +use Pixelshaped\FlatMapperBundle\Mapping\Scalar; +use Pixelshaped\FlatMapperBundle\Mapping\ScalarArray; +use ReflectionClass; use ReflectionProperty; use Symfony\Contracts\Cache\CacheInterface; final class FlatMapper { + // Pre-compiled regex patterns for better performance + private const SNAKE_CASE_PATTERN_1 = '/([A-Z]+)([A-Z][a-z])/'; + private const SNAKE_CASE_PATTERN_2 = '/([a-z\d])([A-Z])/'; + private const SNAKE_CASE_REPLACEMENT = '\1_\2'; + private const MAPPING_NAMESPACE_PREFIX = 'Pixelshaped\\FlatMapperBundle\\Mapping\\'; + + /** + * @var array> + */ + private array $objectIdentifiers = []; + /** + * @var array>> + */ + private array $objectsMapping = []; /** * @var array, - * objectsMapping: array> + * class?: array, + * properties?: array> * }> */ - private array $mappings = []; + private array $yamlMappings = []; private ?CacheInterface $cacheService = null; - private MappingResolver $mappingResolver; - - public function __construct() - { - $this->mappingResolver = new MappingResolver(); - } + private bool $validateMapping = true; public function setCacheService(CacheInterface $cacheService): void { @@ -33,7 +49,7 @@ public function setCacheService(CacheInterface $cacheService): void public function setValidateMapping(bool $validateMapping): void { - $this->mappingResolver->setValidateMapping($validateMapping); + $this->validateMapping = $validateMapping; } /** @@ -44,8 +60,445 @@ public function setValidateMapping(bool $validateMapping): void */ public function setYamlMappings(array $yamlMappings): void { - $this->mappingResolver->setYamlMappings($yamlMappings); - $this->mappings = []; + $this->yamlMappings = $yamlMappings; + + // Mapping source changed, so in-memory compiled mappings need to be rebuilt. + $this->objectIdentifiers = []; + $this->objectsMapping = []; + } + + public function createMapping(string $dtoClassName): void + { + if(!class_exists($dtoClassName)) { + throw new MappingCreationException($dtoClassName.' is not a valid class name'); + } + if(!isset($this->objectsMapping[$dtoClassName])) { + + if($this->cacheService !== null) { + $mappingInfo = $this->cacheService->get($this->createCacheKey($dtoClassName), function () use ($dtoClassName): array { + return $this->createMappingRecursive($dtoClassName); + }); + } else { + $mappingInfo = $this->createMappingRecursive($dtoClassName); + } + + $this->objectsMapping[$dtoClassName] = $mappingInfo['objectsMapping']; + $this->objectIdentifiers[$dtoClassName] = $mappingInfo['objectIdentifiers']; + } + } + + /** + * @param class-string $dtoClassName + * @param array|null $objectIdentifiers + * @param array>|null $objectsMapping + * @return array{'objectIdentifiers': array, "objectsMapping": array>} + */ + private function createMappingRecursive(string $dtoClassName, ?array& $objectIdentifiers = null, ?array& $objectsMapping = null): array + { + if($objectIdentifiers === null) $objectIdentifiers = []; + if($objectsMapping === null) $objectsMapping = []; + + $objectIdentifiers = array_merge([$dtoClassName => 'RESERVED'], $objectIdentifiers); + + $reflectionClass = new ReflectionClass($dtoClassName); + + $constructor = $reflectionClass->getConstructor(); + + if($constructor === null) { + throw new MappingCreationException('Class "' . $dtoClassName . '" does not have a constructor.'); + } + + $identifiersCount = 0; + $transformation = null; + + foreach ($this->getClassMappingAttributes($reflectionClass) as $attribute) { + switch ($attribute['name']) { + case Identifier::class: + $identifierPropertyName = $this->getStringMappingArgument( + $attribute, + 'mappedPropertyName', + sprintf('class "%s"', $dtoClassName) + ); + if ($identifierPropertyName !== null) { + $objectIdentifiers[$dtoClassName] = $identifierPropertyName; + $identifiersCount++; + } else { + throw new MappingCreationException('The Identifier attribute cannot be used without a property name when used as a Class attribute'); + } + break; + + case NameTransformation::class: + try { + /** @var NameTransformation $transformationInstance */ + $transformationInstance = $this->newMappingAttributeInstance($attribute); + $transformation = $transformationInstance; + } catch (Error $e) { + throw new MappingCreationException(sprintf( + 'Invalid NameTransformation attribute for %s:%s%s', + $dtoClassName, + PHP_EOL, + $e->getMessage() + )); + } + } + } + + foreach ($constructor->getParameters() as $reflectionParameter) { + $propertyName = $reflectionParameter->getName(); + $columnName = $transformation + ? $this->transformPropertyName($propertyName, $transformation) + : $propertyName; + $isIdentifier = false; + foreach ($this->getPropertyMappingAttributes($dtoClassName, $propertyName, $reflectionParameter->getAttributes()) as $attribute) { + if ($attribute['name'] === ReferenceArray::class || $attribute['name'] === ScalarArray::class) { + $mappingArgumentName = $attribute['name'] === ReferenceArray::class + ? 'referenceClassName' + : 'mappedPropertyName'; + $mappedProperty = $this->getStringMappingArgument( + $attribute, + $mappingArgumentName, + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + + if($mappedProperty === null) { + throw new MappingCreationException(sprintf( + 'Attribute "%s" on property "%s::$%s" requires a mapped value.', + $attribute['name'], + $dtoClassName, + $propertyName + )); + } + + if($this->validateMapping) { + if((new ReflectionProperty($dtoClassName, $propertyName))->isReadOnly()) { + throw new MappingCreationException($reflectionClass->getName().': property '.$propertyName.' cannot be readonly as it is non-scalar and '.static::class.' needs to access it after object instantiation.'); + } + } + $objectsMapping[$dtoClassName][$propertyName] = $mappedProperty; + if($attribute['name'] === ReferenceArray::class) { + if(!class_exists($mappedProperty)) { + throw new MappingCreationException(sprintf( + 'Invalid reference class "%s" configured on property "%s::$%s".', + $mappedProperty, + $dtoClassName, + $propertyName + )); + } + + /** @var class-string $mappedProperty */ + $this->createMappingRecursive($mappedProperty, $objectIdentifiers, $objectsMapping); + } + continue 2; + } else if ($attribute['name'] === Identifier::class) { + $identifiersCount++; + $isIdentifier = true; + $identifierColumnName = $this->getStringMappingArgument( + $attribute, + 'mappedPropertyName', + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + if($identifierColumnName !== null) { + $columnName = $identifierColumnName; + } + } else if ($attribute['name'] === Scalar::class) { + $scalarColumnName = $this->getStringMappingArgument( + $attribute, + 'mappedPropertyName', + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + if($scalarColumnName !== null) { + $columnName = $scalarColumnName; + } + } + } + + if ($isIdentifier) { + $objectIdentifiers[$dtoClassName] = $columnName; + } + + $objectsMapping[$dtoClassName][$columnName] = null; + } + + if($this->validateMapping) { + if($identifiersCount !== 1) { + throw new MappingCreationException($dtoClassName.' does not contain exactly one #[Identifier] attribute.'); + } + + $uniqueCheck = []; + foreach ($objectIdentifiers as $key => $value) { + if (isset($uniqueCheck[$value])) { + throw new MappingCreationException('Several data identifiers are identical: ' . print_r($objectIdentifiers, true)); + } + $uniqueCheck[$value] = true; + } + } + + return [ + 'objectIdentifiers' => $objectIdentifiers, + 'objectsMapping' => $objectsMapping + ]; + } + + private function transformPropertyName(string $propertyName, NameTransformation $transformation): string + { + if ($transformation->snakeCaseColumns) { + $propertyName = strtolower(preg_replace( + [self::SNAKE_CASE_PATTERN_1, self::SNAKE_CASE_PATTERN_2], + self::SNAKE_CASE_REPLACEMENT, + $propertyName + ) ?? $propertyName); + } + return $transformation->columnPrefix . $propertyName; + } + + /** + * Keep cache keys deterministic while invalidating when YAML mapping changes. + */ + private function createCacheKey(string $dtoClassName): string + { + $cacheKey = strtr($dtoClassName, ['\\' => '_', '-' => '_', ' ' => '_']); + $mappingHash = md5(serialize($this->yamlMappings[$dtoClassName] ?? [])); + + return 'pixelshaped_flat_mapper_'.$cacheKey.'_'.$mappingHash; + } + + /** + * @param ReflectionClass $reflectionClass + * @return list, + * reflectionAttribute?: \ReflectionAttribute + * }> + */ + private function getClassMappingAttributes(ReflectionClass $reflectionClass): array + { + return $this->mergeMappingAttributes( + $reflectionClass->getName(), + $reflectionClass->getAttributes() + ); + } + + /** + * @param list<\ReflectionAttribute> $reflectionAttributes + * @return list, + * reflectionAttribute?: \ReflectionAttribute + * }> + */ + private function getPropertyMappingAttributes(string $dtoClassName, string $propertyName, array $reflectionAttributes): array + { + return $this->mergeMappingAttributes( + $dtoClassName, + $reflectionAttributes, + $propertyName + ); + } + + /** + * @param list<\ReflectionAttribute> $reflectionAttributes + * @return list, + * reflectionAttribute?: \ReflectionAttribute + * }> + */ + private function mergeMappingAttributes(string $dtoClassName, array $reflectionAttributes, ?string $propertyName = null): array + { + $mappingAttributes = []; + + foreach ($this->getYamlAttributes($dtoClassName, $propertyName) as $attributeName => $attributeArguments) { + $mappingAttributes[$attributeName] = [ + 'name' => $attributeName, + 'arguments' => $attributeArguments, + ]; + } + + foreach ($reflectionAttributes as $reflectionAttribute) { + $attributeName = $reflectionAttribute->getName(); + if (isset($mappingAttributes[$attributeName])) { + // Ensure reflection attributes override YAML and keep declaration ordering. + unset($mappingAttributes[$attributeName]); + } + $mappingAttributes[$attributeName] = [ + 'name' => $attributeName, + 'arguments' => $reflectionAttribute->getArguments(), + 'reflectionAttribute' => $reflectionAttribute, + ]; + } + + return array_values($mappingAttributes); + } + + /** + * @return array> + */ + private function getYamlAttributes(string $dtoClassName, ?string $propertyName = null): array + { + if (!isset($this->yamlMappings[$dtoClassName])) { + return []; + } + + $classMapping = $this->yamlMappings[$dtoClassName]; + if (!is_array($classMapping)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for class "%s". Expected an array.', + $dtoClassName + )); + } + + if ($propertyName === null) { + $rawAttributes = $classMapping['class'] ?? []; + return $this->normalizeYamlAttributeMap( + $rawAttributes, + sprintf('class "%s"', $dtoClassName) + ); + } + + $rawProperties = $classMapping['properties'] ?? []; + if (!is_array($rawProperties)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for class "%s". The "properties" section must be an array.', + $dtoClassName + )); + } + + $rawAttributes = $rawProperties[$propertyName] ?? []; + return $this->normalizeYamlAttributeMap( + $rawAttributes, + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + } + + /** + * @return array> + */ + private function normalizeYamlAttributeMap(mixed $rawAttributes, string $mappingTarget): array + { + if ($rawAttributes === null) { + return []; + } + + if (!is_array($rawAttributes)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Expected an attribute map array.', + $mappingTarget + )); + } + + $normalizedAttributes = []; + foreach ($rawAttributes as $attributeName => $rawArguments) { + if (!is_string($attributeName)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Attribute names must be strings.', + $mappingTarget + )); + } + + $resolvedAttributeName = $this->resolveAttributeClassName($attributeName, $mappingTarget); + $normalizedAttributes[$resolvedAttributeName] = $this->normalizeYamlAttributeArguments( + $rawArguments, + $attributeName, + $mappingTarget + ); + } + + return $normalizedAttributes; + } + + /** + * @return array + */ + private function normalizeYamlAttributeArguments(mixed $rawArguments, string $attributeName, string $mappingTarget): array + { + if ($rawArguments === null) { + return []; + } + + if (is_scalar($rawArguments)) { + return [$rawArguments]; + } + + if (is_array($rawArguments)) { + return $rawArguments; + } + + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for attribute "%s" on %s. Expected null, scalar, or array arguments.', + $attributeName, + $mappingTarget + )); + } + + /** + * @return class-string + */ + private function resolveAttributeClassName(string $attributeName, string $mappingTarget): string + { + $className = str_contains($attributeName, '\\') + ? $attributeName + : self::MAPPING_NAMESPACE_PREFIX.$attributeName; + + if (!class_exists($className)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Attribute class "%s" does not exist.', + $mappingTarget, + $className + )); + } + + return $className; + } + + /** + * @param array{ + * name: class-string, + * arguments: array, + * reflectionAttribute?: \ReflectionAttribute + * } $attribute + */ + private function getStringMappingArgument(array $attribute, string $argumentName, string $mappingTarget): ?string + { + if (array_key_exists(0, $attribute['arguments'])) { + $argument = $attribute['arguments'][0]; + } elseif (array_key_exists($argumentName, $attribute['arguments'])) { + $argument = $attribute['arguments'][$argumentName]; + } else { + $argument = null; + } + + if ($argument === null) { + return null; + } + + if (!is_string($argument)) { + throw new MappingCreationException(sprintf( + 'Invalid %s argument for attribute "%s" on %s. Expected string, got %s.', + $argumentName, + $attribute['name'], + $mappingTarget, + get_debug_type($argument) + )); + } + + return $argument; + } + + /** + * @param array{ + * name: class-string, + * arguments: array, + * reflectionAttribute?: \ReflectionAttribute + * } $attribute + */ + private function newMappingAttributeInstance(array $attribute): object + { + if (isset($attribute['reflectionAttribute'])) { + return $attribute['reflectionAttribute']->newInstance(); + } + + $attributeClassName = $attribute['name']; + return new $attributeClassName(...$attribute['arguments']); } /** @@ -57,24 +510,21 @@ public function setYamlMappings(array $yamlMappings): void public function map(string $dtoClassName, iterable $data): array { $this->createMapping($dtoClassName); - $mapping = $this->mappings[$dtoClassName]; - $objectIdentifiers = $mapping['objectIdentifiers']; - $objectsMapping = $mapping['objectsMapping']; $objectsMap = []; $referencesMap = []; foreach ($data as $row) { - foreach ($objectIdentifiers as $objectClass => $identifier) { + foreach ($this->objectIdentifiers[$dtoClassName] as $objectClass => $identifier) { if (!array_key_exists($identifier, $row)) { throw new MappingException('Identifier not found: ' . $identifier); } if ($row[$identifier] !== null && !isset($objectsMap[$identifier][$row[$identifier]])) { $constructorValues = []; - foreach ($objectsMapping[$objectClass] as $objectProperty => $foreignObjectClassOrIdentifier) { + foreach ($this->objectsMapping[$dtoClassName][$objectClass] as $objectProperty => $foreignObjectClassOrIdentifier) { if($foreignObjectClassOrIdentifier !== null) { - if (isset($objectsMapping[$foreignObjectClassOrIdentifier])) { + if (isset($this->objectsMapping[$dtoClassName][$foreignObjectClassOrIdentifier])) { // Handles ReferenceArray attribute - $foreignIdentifier = $objectIdentifiers[$foreignObjectClassOrIdentifier]; + $foreignIdentifier = $this->objectIdentifiers[$dtoClassName][$foreignObjectClassOrIdentifier]; if($row[$foreignIdentifier] !== null) { $referencesMap[$objectClass][$row[$identifier]][$objectProperty][$row[$foreignIdentifier]] = $objectsMap[$foreignObjectClassOrIdentifier][$row[$foreignIdentifier]]; } @@ -108,24 +558,6 @@ public function map(string $dtoClassName, iterable $data): array { return $rootObjects; } - public function createMapping(string $dtoClassName): void - { - if(!class_exists($dtoClassName)) { - throw new MappingCreationException($dtoClassName.' is not a valid class name'); - } - if(!isset($this->mappings[$dtoClassName])) { - if($this->cacheService !== null) { - $mappingInfo = $this->cacheService->get($this->createCacheKey($dtoClassName), function () use ($dtoClassName): array { - return $this->mappingResolver->resolve($dtoClassName); - }); - } else { - $mappingInfo = $this->mappingResolver->resolve($dtoClassName); - } - - $this->mappings[$dtoClassName] = $mappingInfo; - } - } - /** * @template T of object * @param array, array>> $referencesMap @@ -144,15 +576,4 @@ private function linkObjects(array $referencesMap, array $objectsMap): void } } } - - /** - * Keep cache keys deterministic while invalidating when YAML mapping changes. - */ - private function createCacheKey(string $dtoClassName): string - { - $cacheKey = strtr($dtoClassName, ['\\' => '_', '-' => '_', ' ' => '_']); - $mappingHash = $this->mappingResolver->getCacheSignature($dtoClassName); - - return 'pixelshaped_flat_mapper_'.$cacheKey.'_'.$mappingHash; - } } diff --git a/src/MappingResolver.php b/src/MappingResolver.php deleted file mode 100644 index ec45f7f..0000000 --- a/src/MappingResolver.php +++ /dev/null @@ -1,470 +0,0 @@ -, - * properties?: array> - * }> - */ - private array $yamlMappings = []; - - public function setValidateMapping(bool $validateMapping): void - { - $this->validateMapping = $validateMapping; - } - - /** - * @param array, - * properties?: array> - * }> $yamlMappings - */ - public function setYamlMappings(array $yamlMappings): void - { - $this->yamlMappings = $yamlMappings; - } - - public function getCacheSignature(string $dtoClassName): string - { - return md5(serialize($this->yamlMappings[$dtoClassName] ?? [])); - } - - /** - * @param class-string $dtoClassName - * @return array{'objectIdentifiers': array, "objectsMapping": array>} - */ - public function resolve(string $dtoClassName): array - { - if(!class_exists($dtoClassName)) { - throw new MappingCreationException($dtoClassName.' is not a valid class name'); - } - - return $this->createMappingRecursive($dtoClassName); - } - - /** - * @param class-string $dtoClassName - * @param array|null $objectIdentifiers - * @param array>|null $objectsMapping - * @return array{'objectIdentifiers': array, "objectsMapping": array>} - */ - private function createMappingRecursive(string $dtoClassName, ?array& $objectIdentifiers = null, ?array& $objectsMapping = null): array - { - if($objectIdentifiers === null) $objectIdentifiers = []; - if($objectsMapping === null) $objectsMapping = []; - - $objectIdentifiers = array_merge([$dtoClassName => 'RESERVED'], $objectIdentifiers); - - $reflectionClass = new ReflectionClass($dtoClassName); - - $constructor = $reflectionClass->getConstructor(); - - if($constructor === null) { - throw new MappingCreationException('Class "' . $dtoClassName . '" does not have a constructor.'); - } - - $identifiersCount = 0; - $transformation = null; - - foreach ($this->getClassMappingAttributes($reflectionClass) as $attribute) { - switch ($attribute['name']) { - case Identifier::class: - $identifierPropertyName = $this->getStringMappingArgument( - $attribute, - 'mappedPropertyName', - sprintf('class "%s"', $dtoClassName) - ); - if ($identifierPropertyName !== null) { - $objectIdentifiers[$dtoClassName] = $identifierPropertyName; - $identifiersCount++; - } else { - throw new MappingCreationException('The Identifier attribute cannot be used without a property name when used as a Class attribute'); - } - break; - - case NameTransformation::class: - try { - /** @var NameTransformation $transformationInstance */ - $transformationInstance = $this->newMappingAttributeInstance($attribute); - $transformation = $transformationInstance; - } catch (Error $e) { - throw new MappingCreationException(sprintf( - 'Invalid NameTransformation attribute for %s:%s%s', - $dtoClassName, - PHP_EOL, - $e->getMessage() - )); - } - } - } - - foreach ($constructor->getParameters() as $reflectionParameter) { - $propertyName = $reflectionParameter->getName(); - $columnName = $transformation - ? $this->transformPropertyName($propertyName, $transformation) - : $propertyName; - $isIdentifier = false; - foreach ($this->getPropertyMappingAttributes($dtoClassName, $propertyName, $reflectionParameter->getAttributes()) as $attribute) { - if ($attribute['name'] === ReferenceArray::class || $attribute['name'] === ScalarArray::class) { - $mappingArgumentName = $attribute['name'] === ReferenceArray::class - ? 'referenceClassName' - : 'mappedPropertyName'; - $mappedProperty = $this->getStringMappingArgument( - $attribute, - $mappingArgumentName, - sprintf('property "%s::$%s"', $dtoClassName, $propertyName) - ); - - if($mappedProperty === null) { - throw new MappingCreationException(sprintf( - 'Attribute "%s" on property "%s::$%s" requires a mapped value.', - $attribute['name'], - $dtoClassName, - $propertyName - )); - } - - if($this->validateMapping) { - if((new ReflectionProperty($dtoClassName, $propertyName))->isReadOnly()) { - throw new MappingCreationException($reflectionClass->getName().': property '.$propertyName.' cannot be readonly as it is non-scalar and '.FlatMapper::class.' needs to access it after object instantiation.'); - } - } - $objectsMapping[$dtoClassName][$propertyName] = $mappedProperty; - if($attribute['name'] === ReferenceArray::class) { - if(!class_exists($mappedProperty)) { - throw new MappingCreationException(sprintf( - 'Invalid reference class "%s" configured on property "%s::$%s".', - $mappedProperty, - $dtoClassName, - $propertyName - )); - } - - /** @var class-string $mappedProperty */ - $this->createMappingRecursive($mappedProperty, $objectIdentifiers, $objectsMapping); - } - continue 2; - } else if ($attribute['name'] === Identifier::class) { - $identifiersCount++; - $isIdentifier = true; - $identifierColumnName = $this->getStringMappingArgument( - $attribute, - 'mappedPropertyName', - sprintf('property "%s::$%s"', $dtoClassName, $propertyName) - ); - if($identifierColumnName !== null) { - $columnName = $identifierColumnName; - } - } else if ($attribute['name'] === Scalar::class) { - $scalarColumnName = $this->getStringMappingArgument( - $attribute, - 'mappedPropertyName', - sprintf('property "%s::$%s"', $dtoClassName, $propertyName) - ); - if($scalarColumnName !== null) { - $columnName = $scalarColumnName; - } - } - } - - if ($isIdentifier) { - $objectIdentifiers[$dtoClassName] = $columnName; - } - - $objectsMapping[$dtoClassName][$columnName] = null; - } - - if($this->validateMapping) { - if($identifiersCount !== 1) { - throw new MappingCreationException($dtoClassName.' does not contain exactly one #[Identifier] attribute.'); - } - - $uniqueCheck = []; - foreach ($objectIdentifiers as $value) { - if (isset($uniqueCheck[$value])) { - throw new MappingCreationException('Several data identifiers are identical: ' . print_r($objectIdentifiers, true)); - } - $uniqueCheck[$value] = true; - } - } - - return [ - 'objectIdentifiers' => $objectIdentifiers, - 'objectsMapping' => $objectsMapping - ]; - } - - private function transformPropertyName(string $propertyName, NameTransformation $transformation): string - { - if ($transformation->snakeCaseColumns) { - $propertyName = strtolower(preg_replace( - [self::SNAKE_CASE_PATTERN_1, self::SNAKE_CASE_PATTERN_2], - self::SNAKE_CASE_REPLACEMENT, - $propertyName - ) ?? $propertyName); - } - return $transformation->columnPrefix . $propertyName; - } - - /** - * @param ReflectionClass $reflectionClass - * @return list, - * reflectionAttribute?: \ReflectionAttribute - * }> - */ - private function getClassMappingAttributes(ReflectionClass $reflectionClass): array - { - return $this->mergeMappingAttributes( - $reflectionClass->getName(), - $reflectionClass->getAttributes() - ); - } - - /** - * @param list<\ReflectionAttribute> $reflectionAttributes - * @return list, - * reflectionAttribute?: \ReflectionAttribute - * }> - */ - private function getPropertyMappingAttributes(string $dtoClassName, string $propertyName, array $reflectionAttributes): array - { - return $this->mergeMappingAttributes( - $dtoClassName, - $reflectionAttributes, - $propertyName - ); - } - - /** - * @param list<\ReflectionAttribute> $reflectionAttributes - * @return list, - * reflectionAttribute?: \ReflectionAttribute - * }> - */ - private function mergeMappingAttributes(string $dtoClassName, array $reflectionAttributes, ?string $propertyName = null): array - { - $mappingAttributes = []; - - foreach ($this->getYamlAttributes($dtoClassName, $propertyName) as $attributeName => $attributeArguments) { - $mappingAttributes[$attributeName] = [ - 'name' => $attributeName, - 'arguments' => $attributeArguments, - ]; - } - - foreach ($reflectionAttributes as $reflectionAttribute) { - $attributeName = $reflectionAttribute->getName(); - if (isset($mappingAttributes[$attributeName])) { - // Ensure reflection attributes override YAML and keep declaration ordering. - unset($mappingAttributes[$attributeName]); - } - $mappingAttributes[$attributeName] = [ - 'name' => $attributeName, - 'arguments' => $reflectionAttribute->getArguments(), - 'reflectionAttribute' => $reflectionAttribute, - ]; - } - - return array_values($mappingAttributes); - } - - /** - * @return array> - */ - private function getYamlAttributes(string $dtoClassName, ?string $propertyName = null): array - { - if (!isset($this->yamlMappings[$dtoClassName])) { - return []; - } - - $classMapping = $this->yamlMappings[$dtoClassName]; - if (!is_array($classMapping)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for class "%s". Expected an array.', - $dtoClassName - )); - } - - if ($propertyName === null) { - $rawAttributes = $classMapping['class'] ?? []; - return $this->normalizeYamlAttributeMap( - $rawAttributes, - sprintf('class "%s"', $dtoClassName) - ); - } - - $rawProperties = $classMapping['properties'] ?? []; - if (!is_array($rawProperties)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for class "%s". The "properties" section must be an array.', - $dtoClassName - )); - } - - $rawAttributes = $rawProperties[$propertyName] ?? []; - return $this->normalizeYamlAttributeMap( - $rawAttributes, - sprintf('property "%s::$%s"', $dtoClassName, $propertyName) - ); - } - - /** - * @return array> - */ - private function normalizeYamlAttributeMap(mixed $rawAttributes, string $mappingTarget): array - { - if ($rawAttributes === null) { - return []; - } - - if (!is_array($rawAttributes)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for %s. Expected an attribute map array.', - $mappingTarget - )); - } - - $normalizedAttributes = []; - foreach ($rawAttributes as $attributeName => $rawArguments) { - if (!is_string($attributeName)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for %s. Attribute names must be strings.', - $mappingTarget - )); - } - - $resolvedAttributeName = $this->resolveAttributeClassName($attributeName, $mappingTarget); - $normalizedAttributes[$resolvedAttributeName] = $this->normalizeYamlAttributeArguments( - $rawArguments, - $attributeName, - $mappingTarget - ); - } - - return $normalizedAttributes; - } - - /** - * @return array - */ - private function normalizeYamlAttributeArguments(mixed $rawArguments, string $attributeName, string $mappingTarget): array - { - if ($rawArguments === null) { - return []; - } - - if (is_scalar($rawArguments)) { - return [$rawArguments]; - } - - if (is_array($rawArguments)) { - return $rawArguments; - } - - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for attribute "%s" on %s. Expected null, scalar, or array arguments.', - $attributeName, - $mappingTarget - )); - } - - /** - * @return class-string - */ - private function resolveAttributeClassName(string $attributeName, string $mappingTarget): string - { - $className = str_contains($attributeName, '\\') - ? $attributeName - : self::MAPPING_NAMESPACE_PREFIX.$attributeName; - - if (!class_exists($className)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for %s. Attribute class "%s" does not exist.', - $mappingTarget, - $className - )); - } - - return $className; - } - - /** - * @param array{ - * name: class-string, - * arguments: array, - * reflectionAttribute?: \ReflectionAttribute - * } $attribute - */ - private function getStringMappingArgument(array $attribute, string $argumentName, string $mappingTarget): ?string - { - if (array_key_exists(0, $attribute['arguments'])) { - $argument = $attribute['arguments'][0]; - } elseif (array_key_exists($argumentName, $attribute['arguments'])) { - $argument = $attribute['arguments'][$argumentName]; - } else { - $argument = null; - } - - if ($argument === null) { - return null; - } - - if (!is_string($argument)) { - throw new MappingCreationException(sprintf( - 'Invalid %s argument for attribute "%s" on %s. Expected string, got %s.', - $argumentName, - $attribute['name'], - $mappingTarget, - get_debug_type($argument) - )); - } - - return $argument; - } - - /** - * @param array{ - * name: class-string, - * arguments: array, - * reflectionAttribute?: \ReflectionAttribute - * } $attribute - */ - private function newMappingAttributeInstance(array $attribute): object - { - if (isset($attribute['reflectionAttribute'])) { - return $attribute['reflectionAttribute']->newInstance(); - } - - $attributeClassName = $attribute['name']; - return new $attributeClassName(...$attribute['arguments']); - } -} diff --git a/tests/FlatMapperCreateMappingTest.php b/tests/FlatMapperCreateMappingTest.php index 37b0de5..28aaae7 100644 --- a/tests/FlatMapperCreateMappingTest.php +++ b/tests/FlatMapperCreateMappingTest.php @@ -8,7 +8,6 @@ use PHPUnit\Framework\TestCase; use Pixelshaped\FlatMapperBundle\Exception\MappingCreationException; use Pixelshaped\FlatMapperBundle\FlatMapper; -use Pixelshaped\FlatMapperBundle\MappingResolver; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\NameTransformation\InvalidNameTransformationDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTO as InvalidRootDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithEmptyClassIdentifier; @@ -24,10 +23,10 @@ use Symfony\Contracts\Cache\CacheInterface; #[CoversMethod(FlatMapper::class, 'createMapping')] +#[CoversMethod(FlatMapper::class, 'createMappingRecursive')] #[CoversMethod(FlatMapper::class, 'setCacheService')] #[CoversMethod(FlatMapper::class, 'setYamlMappings')] #[CoversClass(FlatMapper::class)] -#[CoversClass(MappingResolver::class)] #[CoversClass(MappingCreationException::class)] class FlatMapperCreateMappingTest extends TestCase { @@ -41,9 +40,14 @@ public function testCreateMappingWithValidDTOsDoesNotAssert(): void public function testCreateMappingWithCacheServiceDoesNotAssert(): void { $flatMapper = new FlatMapper(); + + // The intention is not to test the createMappingRecursive private method + // but to dynamically give the CacheInterface mock a proper return value. + $reflectionMethod = (new \ReflectionClass(FlatMapper::class))->getMethod('createMappingRecursive'); + $reflectionMethod->setAccessible(true); $cacheInterface = $this->createMock(CacheInterface::class); $cacheInterface->expects($this->once())->method('get')->willReturn( - (new MappingResolver())->resolve(AuthorDTO::class) + $reflectionMethod->invoke($flatMapper, AuthorDTO::class) ); $flatMapper->setCacheService($cacheInterface); @@ -72,14 +76,6 @@ public function testCreateMappingWrongClassNameAsserts(): void (new FlatMapper())->createMapping('ThisIsNotAValidClassString'); } - public function testResolveWrongClassNameAsserts(): void - { - $this->expectException(MappingCreationException::class); - $this->expectExceptionMessageMatches("/An error occurred during mapping creation: ThisIsNotAValidClassString is not a valid class name/"); - // @phpstan-ignore argument.type - (new MappingResolver())->resolve('ThisIsNotAValidClassString'); - } - public function testCreateMappingWithSeveralIdenticalIdentifiersAsserts(): void { $this->expectException(MappingCreationException::class); @@ -374,12 +370,12 @@ public function testCreateMappingWithYamlScalarNonStringArgumentAsserts(): void public function testNormalizeYamlAttributeMapWithNullReturnsEmptyArray(): void { - $mappingResolver = new MappingResolver(); - $reflectionMethod = (new \ReflectionClass(MappingResolver::class))->getMethod('normalizeYamlAttributeMap'); + $flatMapper = new FlatMapper(); + $reflectionMethod = (new \ReflectionClass(FlatMapper::class))->getMethod('normalizeYamlAttributeMap'); $reflectionMethod->setAccessible(true); /** @var array> $result */ - $result = $reflectionMethod->invoke($mappingResolver, null, 'class "Foo\\Bar\\Baz"'); + $result = $reflectionMethod->invoke($flatMapper, null, 'class "Foo\\Bar\\Baz"'); $this->assertSame([], $result); } } diff --git a/tests/FlatMapperTest.php b/tests/FlatMapperTest.php index 6a7cfd2..2786ab4 100644 --- a/tests/FlatMapperTest.php +++ b/tests/FlatMapperTest.php @@ -7,7 +7,6 @@ use PHPUnit\Framework\TestCase; use Pixelshaped\FlatMapperBundle\Exception\MappingException; use Pixelshaped\FlatMapperBundle\FlatMapper; -use Pixelshaped\FlatMapperBundle\MappingResolver; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ClassAttributes\AuthorDTO as ClassAttributesAuthorDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ClassAttributes\BookDTO as ClassAttributesBookDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Complex\CustomerDTO; @@ -28,7 +27,6 @@ use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Yaml\BookDTO as YamlBookDTO; #[CoversClass(FlatMapper::class)] -#[CoversClass(MappingResolver::class)] #[CoversClass(MappingException::class)] class FlatMapperTest extends TestCase { diff --git a/tests/Functional/BundleFunctionalTest.php b/tests/Functional/BundleFunctionalTest.php index e7fe5b5..1e0d5be 100644 --- a/tests/Functional/BundleFunctionalTest.php +++ b/tests/Functional/BundleFunctionalTest.php @@ -6,7 +6,6 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Pixelshaped\FlatMapperBundle\FlatMapper; -use Pixelshaped\FlatMapperBundle\MappingResolver; use Pixelshaped\FlatMapperBundle\PixelshapedFlatMapperBundle; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\WithoutAttributeDTO; use Symfony\Component\Config\Loader\LoaderInterface; @@ -15,7 +14,6 @@ use Symfony\Contracts\Cache\ItemInterface; #[CoversClass(FlatMapper::class)] -#[CoversClass(MappingResolver::class)] #[CoversClass(PixelshapedFlatMapperBundle::class)] class BundleFunctionalTest extends TestCase { From fb15820903c4e10d4d64f873a739ca68925b7290 Mon Sep 17 00:00:00 2001 From: Renaud Date: Mon, 16 Feb 2026 10:01:30 +0100 Subject: [PATCH 05/14] Merge functional tests --- tests/Functional/BundleFunctionalTest.php | 171 ---------------------- tests/PixelshapedFlatMapperBundleTest.php | 111 ++++++++++++++ 2 files changed, 111 insertions(+), 171 deletions(-) delete mode 100644 tests/Functional/BundleFunctionalTest.php diff --git a/tests/Functional/BundleFunctionalTest.php b/tests/Functional/BundleFunctionalTest.php deleted file mode 100644 index ac2fb08..0000000 --- a/tests/Functional/BundleFunctionalTest.php +++ /dev/null @@ -1,171 +0,0 @@ -isDir()) { - rmdir($item->getPathname()); - continue; - } - unlink($item->getPathname()); - } - rmdir($cacheDir); - } - - public function testServiceWiring(): void - { - $kernel = new PixelshapedFlatMapperTestingKernel('test', true); - $kernel->boot(); - $container = $kernel->getContainer(); - $flatMapper = $container->get('pixelshaped_flat_mapper.flat_mapper'); - $this->assertInstanceOf(FlatMapper::class, $flatMapper); - } - - public function testServiceWiringWithCacheService(): void - { - $kernel = new PixelshapedFlatMapperTestingKernelWithCache('test', true); - $kernel->boot(); - $container = $kernel->getContainer(); - $flatMapper = $container->get('pixelshaped_flat_mapper.flat_mapper'); - $this->assertInstanceOf(FlatMapper::class, $flatMapper); - } - - public function testServiceWiringWithYamlMappings(): void - { - $kernel = new PixelshapedFlatMapperTestingKernelWithMappings('test', true); - $kernel->boot(); - $container = $kernel->getContainer(); - $flatMapper = $container->get('pixelshaped_flat_mapper.flat_mapper'); - - $this->assertInstanceOf(FlatMapper::class, $flatMapper); - - $mapped = $flatMapper->map(WithoutAttributeDTO::class, [ - ['row_id' => 1, 'row_foo' => 'Foo 1', 'row_bar' => 2], - ]); - - $this->assertEquals([1 => new WithoutAttributeDTO(1, 'Foo 1', 2)], $mapped); - } -} - -class PixelshapedFlatMapperTestingKernel extends Kernel -{ - public function registerBundles(): iterable - { - return [ - new PixelshapedFlatMapperBundle(), - ]; - } - public function registerContainerConfiguration(LoaderInterface $loader): void - { - } -} - -class PixelshapedFlatMapperTestingKernelWithCache extends Kernel -{ - public function registerBundles(): iterable - { - return [ - new PixelshapedFlatMapperBundle(), - ]; - } - public function registerContainerConfiguration(LoaderInterface $loader): void - { - $loader->load(function ($container) { - // Register a mock cache service - $container->register('cache.app', MockCacheAdapter::class); - - $container->loadFromExtension('pixelshaped_flat_mapper', [ - 'cache_service' => 'cache.app', - 'validate_mapping' => false, - ]); - }); - } -} - -class PixelshapedFlatMapperTestingKernelWithMappings extends Kernel -{ - public function registerBundles(): iterable - { - return [ - new PixelshapedFlatMapperBundle(), - ]; - } - public function registerContainerConfiguration(LoaderInterface $loader): void - { - $loader->load(function ($container) { - $container->loadFromExtension('pixelshaped_flat_mapper', [ - 'mappings' => [ - WithoutAttributeDTO::class => [ - 'properties' => [ - 'id' => ['Scalar' => 'row_id'], - 'foo' => ['Scalar' => 'row_foo'], - 'bar' => ['Scalar' => 'row_bar'], - ], - ], - ], - ]); - }); - } -} - -class MockCacheAdapter implements CacheInterface -{ - /** - * @param array|null $metadata - */ - public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed - { - return $callback($this->createMockItem(), false); - } - - public function delete(string $key): bool - { - return true; - } - - private function createMockItem(): ItemInterface - { - return new class implements ItemInterface { - public function getKey(): string { return 'test'; } - public function get(): mixed { return null; } - public function isHit(): bool { return false; } - public function set(mixed $value): static { return $this; } - public function expiresAt(?\DateTimeInterface $expiration): static { return $this; } - public function expiresAfter(int|\DateInterval|null $time): static { return $this; } - public function tag(iterable|string $tags): static { return $this; } - /** @return array */ - public function getMetadata(): array { return []; } - }; - } -} diff --git a/tests/PixelshapedFlatMapperBundleTest.php b/tests/PixelshapedFlatMapperBundleTest.php index 75c397b..70fc56a 100644 --- a/tests/PixelshapedFlatMapperBundleTest.php +++ b/tests/PixelshapedFlatMapperBundleTest.php @@ -3,21 +3,30 @@ namespace Pixelshaped\FlatMapperBundle\Tests; +use FilesystemIterator; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Pixelshaped\FlatMapperBundle\FlatMapper; use Pixelshaped\FlatMapperBundle\PixelshapedFlatMapperBundle; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\WithoutAttributeDTO; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; use Symfony\Component\Config\Definition\Loader\DefinitionFileLoader; use Symfony\Component\Config\Definition\Processor; use Symfony\Component\Config\FileLocator; +use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +#[CoversClass(FlatMapper::class)] #[CoversClass(PixelshapedFlatMapperBundle::class)] class PixelshapedFlatMapperBundleTest extends TestCase { @@ -107,6 +116,24 @@ public function testLoadExtensionWithNonStringCacheServiceAsserts(): void ); } + public function testBundleWiringWithCacheAndYamlMappingsWorksEndToEnd(): void + { + $this->clearKernelCacheDir(); + + $kernel = new PixelshapedFlatMapperTestingKernelWithCacheAndMappings('test', true); + $kernel->boot(); + $container = $kernel->getContainer(); + $flatMapper = $container->get('pixelshaped_flat_mapper.flat_mapper'); + + $this->assertInstanceOf(FlatMapper::class, $flatMapper); + + $mapped = $flatMapper->map(WithoutAttributeDTO::class, [ + ['row_id' => 1, 'row_foo' => 'Foo 1', 'row_bar' => 2], + ]); + + $this->assertEquals([1 => new WithoutAttributeDTO(1, 'Foo 1', 2)], $mapped); + } + private function createContainerConfigurator(ContainerBuilder $containerBuilder): ContainerConfigurator { $instanceof = []; @@ -122,4 +149,88 @@ private function createContainerConfigurator(ContainerBuilder $containerBuilder) 'test' ); } + + private function clearKernelCacheDir(): void + { + $cacheDir = dirname(__DIR__).'/var/cache/test'; + if (!is_dir($cacheDir)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($cacheDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + /** @var \SplFileInfo $item */ + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + continue; + } + unlink($item->getPathname()); + } + rmdir($cacheDir); + } +} + +class PixelshapedFlatMapperTestingKernelWithCacheAndMappings extends Kernel +{ + public function registerBundles(): iterable + { + return [ + new PixelshapedFlatMapperBundle(), + ]; + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(function ($container) { + $container->register('cache.app', PixelshapedFlatMapperMockCacheAdapter::class); + + $container->loadFromExtension('pixelshaped_flat_mapper', [ + 'cache_service' => 'cache.app', + 'validate_mapping' => false, + 'mappings' => [ + WithoutAttributeDTO::class => [ + 'properties' => [ + 'id' => ['Scalar' => 'row_id'], + 'foo' => ['Scalar' => 'row_foo'], + 'bar' => ['Scalar' => 'row_bar'], + ], + ], + ], + ]); + }); + } +} + +class PixelshapedFlatMapperMockCacheAdapter implements CacheInterface +{ + /** + * @param array|null $metadata + */ + public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed + { + return $callback($this->createMockItem(), false); + } + + public function delete(string $key): bool + { + return true; + } + + private function createMockItem(): ItemInterface + { + return new class implements ItemInterface { + public function getKey(): string { return 'test'; } + public function get(): mixed { return null; } + public function isHit(): bool { return false; } + public function set(mixed $value): static { return $this; } + public function expiresAt(?\DateTimeInterface $expiration): static { return $this; } + public function expiresAfter(int|\DateInterval|null $time): static { return $this; } + public function tag(iterable|string $tags): static { return $this; } + /** @return array */ + public function getMetadata(): array { return []; } + }; + } } From 9409290f2ff1ad5433ced9679b5e9f319f6a0739 Mon Sep 17 00:00:00 2001 From: Renaud Date: Mon, 16 Feb 2026 10:45:06 +0100 Subject: [PATCH 06/14] Remove useless mock item --- tests/PixelshapedFlatMapperBundleTest.php | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/tests/PixelshapedFlatMapperBundleTest.php b/tests/PixelshapedFlatMapperBundleTest.php index 70fc56a..fdc5df8 100644 --- a/tests/PixelshapedFlatMapperBundleTest.php +++ b/tests/PixelshapedFlatMapperBundleTest.php @@ -22,7 +22,6 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\Kernel; use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -211,26 +210,11 @@ class PixelshapedFlatMapperMockCacheAdapter implements CacheInterface */ public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed { - return $callback($this->createMockItem(), false); + return $callback(); } public function delete(string $key): bool { return true; } - - private function createMockItem(): ItemInterface - { - return new class implements ItemInterface { - public function getKey(): string { return 'test'; } - public function get(): mixed { return null; } - public function isHit(): bool { return false; } - public function set(mixed $value): static { return $this; } - public function expiresAt(?\DateTimeInterface $expiration): static { return $this; } - public function expiresAfter(int|\DateInterval|null $time): static { return $this; } - public function tag(iterable|string $tags): static { return $this; } - /** @return array */ - public function getMetadata(): array { return []; } - }; - } } From 44c1906c40cd3a7e4a355a4bc5aecdb017407afc Mon Sep 17 00:00:00 2001 From: Renaud Date: Mon, 16 Feb 2026 11:01:27 +0100 Subject: [PATCH 07/14] Fix potential YAML cache issue --- src/FlatMapper.php | 2 +- tests/FlatMapperCreateMappingTest.php | 71 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/FlatMapper.php b/src/FlatMapper.php index 48eb4af..4f04767 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -300,7 +300,7 @@ private function transformPropertyName(string $propertyName, NameTransformation private function createCacheKey(string $dtoClassName): string { $cacheKey = strtr($dtoClassName, ['\\' => '_', '-' => '_', ' ' => '_']); - $mappingHash = md5(serialize($this->yamlMappings[$dtoClassName] ?? [])); + $mappingHash = md5(serialize($this->yamlMappings)); return 'pixelshaped_flat_mapper_'.$cacheKey.'_'.$mappingHash; } diff --git a/tests/FlatMapperCreateMappingTest.php b/tests/FlatMapperCreateMappingTest.php index 3ef6a80..de88bd5 100644 --- a/tests/FlatMapperCreateMappingTest.php +++ b/tests/FlatMapperCreateMappingTest.php @@ -29,6 +29,7 @@ use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Yaml\AuthorDTO as YamlAuthorDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Yaml\BookDTO as YamlBookDTO; use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; #[CoversMethod(FlatMapper::class, 'createMapping')] #[CoversMethod(FlatMapper::class, 'createMappingRecursive')] @@ -78,6 +79,76 @@ function (string $key, callable $callback) { $flatMapper->createMapping(AuthorDTO::class); } + public function testCreateMappingWithCacheServiceInvalidatesRootCacheWhenNestedYamlMappingChanges(): void + { + $cachedMappings = []; + $cacheItem = $this->createStub(ItemInterface::class); + $cache = $this->createMock(CacheInterface::class); + $cache->method('get')->willReturnCallback( + function (string $key, callable $callback) use (&$cachedMappings, $cacheItem): mixed { + if (!array_key_exists($key, $cachedMappings)) { + $save = true; + $cachedMappings[$key] = $callback($cacheItem, $save); + } + + return $cachedMappings[$key]; + } + ); + + $flatMapper = new FlatMapper(); + $flatMapper->setCacheService($cache); + + $authorMapping = [ + 'class' => [ + 'NameTransformation' => ['columnPrefix' => 'author_'], + ], + 'properties' => [ + 'id' => ['Identifier' => null], + 'books' => ['ReferenceArray' => YamlBookDTO::class], + ], + ]; + + $flatMapper->setYamlMappings([ + YamlAuthorDTO::class => $authorMapping, + YamlBookDTO::class => [ + 'class' => [ + 'NameTransformation' => ['columnPrefix' => 'book_', 'snakeCaseColumns' => true], + ], + 'properties' => [ + 'id' => ['Identifier' => null], + ], + ], + ]); + $flatMapper->map(YamlAuthorDTO::class, [[ + 'author_id' => 1, + 'author_name' => 'Alice', + 'book_id' => 10, + 'book_name' => 'Original title', + 'book_publisher_name' => 'Original publisher', + ]]); + + $flatMapper->setYamlMappings([ + YamlAuthorDTO::class => $authorMapping, + YamlBookDTO::class => [ + 'properties' => [ + 'id' => ['Identifier' => 'book_id'], + 'name' => ['Scalar' => 'book_title'], + 'publisherName' => ['Scalar' => 'book_publisher'], + ], + ], + ]); + $mappedResults = $flatMapper->map(YamlAuthorDTO::class, [[ + 'author_id' => 1, + 'author_name' => 'Alice', + 'book_id' => 10, + 'book_title' => 'Updated title', + 'book_publisher' => 'Updated publisher', + ]]); + + $this->assertSame('Updated title', $mappedResults[1]->books[10]->name); + $this->assertSame('Updated publisher', $mappedResults[1]->books[10]->publisherName); + } + public function testCreateMappingWrongClassNameAsserts(): void { $this->expectException(MappingCreationException::class); From 54d4eb8aeebf227e9a283b9c5a3265736e581627 Mon Sep 17 00:00:00 2001 From: Renaud Date: Mon, 16 Feb 2026 11:03:51 +0100 Subject: [PATCH 08/14] Prevent cache name collisions --- src/FlatMapper.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/FlatMapper.php b/src/FlatMapper.php index 4f04767..dabb012 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -299,10 +299,10 @@ private function transformPropertyName(string $propertyName, NameTransformation */ private function createCacheKey(string $dtoClassName): string { - $cacheKey = strtr($dtoClassName, ['\\' => '_', '-' => '_', ' ' => '_']); - $mappingHash = md5(serialize($this->yamlMappings)); + $cacheKey = sha1($dtoClassName); + $mappingsHash = sha1(serialize($this->yamlMappings)); - return 'pixelshaped_flat_mapper_'.$cacheKey.'_'.$mappingHash; + return 'pixelshaped_flat_mapper_'.$cacheKey.'_'.$mappingsHash; } /** From db4f551bbba7de710e7a2894375bbf62bd6f01ce Mon Sep 17 00:00:00 2001 From: Renaud Date: Mon, 16 Feb 2026 11:06:38 +0100 Subject: [PATCH 09/14] Add non-string-scalar mapping test --- src/FlatMapper.php | 6 +++--- .../RootDTOWithNonStringScalarArgument.php | 18 ++++++++++++++++++ tests/FlatMapperCreateMappingTest.php | 8 ++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 tests/Examples/Invalid/RootDTOWithNonStringScalarArgument.php diff --git a/src/FlatMapper.php b/src/FlatMapper.php index dabb012..1c8ca4b 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -267,16 +267,16 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde /** * @param ReflectionAttribute $attribute */ - private function getAttributeArgument(ReflectionAttribute $attribute, string $argumentName): ?string + private function getAttributeArgument(ReflectionAttribute $attribute, string $argumentName): mixed { $arguments = $attribute->getArguments(); if (array_key_exists($argumentName, $arguments)) { - return $arguments[$argumentName] === null ? null : (string)$arguments[$argumentName]; + return $arguments[$argumentName]; } if (array_key_exists(0, $arguments)) { - return $arguments[0] === null ? null : (string)$arguments[0]; + return $arguments[0]; } return null; diff --git a/tests/Examples/Invalid/RootDTOWithNonStringScalarArgument.php b/tests/Examples/Invalid/RootDTOWithNonStringScalarArgument.php new file mode 100644 index 0000000..422ed74 --- /dev/null +++ b/tests/Examples/Invalid/RootDTOWithNonStringScalarArgument.php @@ -0,0 +1,18 @@ +createMapping(RootDTOWithInvalidScalarAttribute::class); } + public function testCreateMappingWithNonStringScalarArgumentAsserts(): void + { + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Expected string, got int/'); + (new FlatMapper())->createMapping(RootDTOWithNonStringScalarArgument::class); + } + public function testCreateMappingWithYamlMappingsDoesNotAssert(): void { $flatMapper = new FlatMapper(); From 3180b9e9d2f95a101b844c3f7f33236069e4bdf9 Mon Sep 17 00:00:00 2001 From: Renaud Date: Mon, 16 Feb 2026 11:12:20 +0100 Subject: [PATCH 10/14] Whitelist mappings attributes --- src/FlatMapper.php | 13 +++++++++++-- tests/FlatMapperCreateMappingTest.php | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/FlatMapper.php b/src/FlatMapper.php index 1c8ca4b..2dbd191 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -478,10 +478,19 @@ private function normalizeYamlAttributeArguments(mixed $rawArguments, string $at */ private function resolveAttributeClassName(string $attributeName, string $mappingTarget): string { - $className = str_contains($attributeName, '\\') - ? $attributeName + $normalizedAttributeName = ltrim($attributeName, '\\'); + $className = str_contains($normalizedAttributeName, '\\') + ? $normalizedAttributeName : self::MAPPING_NAMESPACE_PREFIX.$attributeName; + if (!str_starts_with($className, self::MAPPING_NAMESPACE_PREFIX)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Unsupported mapping attribute class "%s".', + $mappingTarget, + $className + )); + } + if (!class_exists($className)) { throw new MappingCreationException(sprintf( 'Invalid YAML mapping for %s. Attribute class "%s" does not exist.', diff --git a/tests/FlatMapperCreateMappingTest.php b/tests/FlatMapperCreateMappingTest.php index 70d0fe6..f51a005 100644 --- a/tests/FlatMapperCreateMappingTest.php +++ b/tests/FlatMapperCreateMappingTest.php @@ -335,6 +335,22 @@ public function testCreateMappingWithInvalidYamlAttributeClassAsserts(): void $flatMapper->createMapping(YamlAuthorDTO::class); } + public function testCreateMappingWithUnsupportedYamlAttributeClassAsserts(): void + { + $flatMapper = new FlatMapper(); + $flatMapper->setYamlMappings([ + YamlBookDTO::class => [ + 'properties' => [ + 'id' => [FlatMapper::class => []], + ], + ], + ]); + + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Unsupported mapping attribute class/'); + $flatMapper->createMapping(YamlBookDTO::class); + } + public function testCreateMappingWithYamlClassAttributesSetToNullDoesNotAssert(): void { $flatMapper = new FlatMapper(); From ac6897b55d7f649b5b75b77d9fbb1434fcb0c2f8 Mon Sep 17 00:00:00 2001 From: Renaud Date: Mon, 16 Feb 2026 13:26:32 +0100 Subject: [PATCH 11/14] Fix stan --- tests/PixelshapedFlatMapperBundleTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/PixelshapedFlatMapperBundleTest.php b/tests/PixelshapedFlatMapperBundleTest.php index fdc5df8..75f45de 100644 --- a/tests/PixelshapedFlatMapperBundleTest.php +++ b/tests/PixelshapedFlatMapperBundleTest.php @@ -9,6 +9,8 @@ use Pixelshaped\FlatMapperBundle\FlatMapper; use Pixelshaped\FlatMapperBundle\PixelshapedFlatMapperBundle; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\WithoutAttributeDTO; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; use Symfony\Component\Config\Definition\Loader\DefinitionFileLoader; @@ -22,8 +24,6 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\Kernel; use Symfony\Contracts\Cache\CacheInterface; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; #[CoversClass(FlatMapper::class)] #[CoversClass(PixelshapedFlatMapperBundle::class)] @@ -210,6 +210,7 @@ class PixelshapedFlatMapperMockCacheAdapter implements CacheInterface */ public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed { + /** @phpstan-ignore-next-line */ return $callback(); } From 1f9bc0d2cfccc381810308c932a33d8a4b9d4a6e Mon Sep 17 00:00:00 2001 From: Renaud Date: Mon, 16 Feb 2026 14:22:01 +0100 Subject: [PATCH 12/14] Refacto some tests --- tests/FlatMapperCreateMappingTest.php | 26 +++++++++++++++----------- tests/FlatMapperTest.php | 20 +++----------------- 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/tests/FlatMapperCreateMappingTest.php b/tests/FlatMapperCreateMappingTest.php index f51a005..94f72dd 100644 --- a/tests/FlatMapperCreateMappingTest.php +++ b/tests/FlatMapperCreateMappingTest.php @@ -5,22 +5,23 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Pixelshaped\FlatMapperBundle\Exception\MappingCreationException; use Pixelshaped\FlatMapperBundle\FlatMapper; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\Circular\CycleRootDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\NameTransformation\InvalidNameTransformationDTO; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootAbstractDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTO as InvalidRootDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithEmptyClassIdentifier; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithEmptyStringClassIdentifier; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithEmptyStringPropertyIdentifier; -use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootAbstractDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithInvalidReferenceArrayAttribute; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithInvalidReferenceArrayClass; -use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithNonStringScalarArgument; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithInvalidScalarArrayAttribute; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithInvalidScalarAttribute; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithNoIdentifier; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithNonStringScalarArgument; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithoutConstructor; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithReadonlyClassModifier; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithTooManyIdentifiers; @@ -164,11 +165,21 @@ public function testCreateMappingWithSeveralIdenticalIdentifiersAsserts(): void (new FlatMapper())->createMapping(InvalidRootDTO::class); } - public function testCreateMappingWithTooManyIdentifiersAsserts(): void + #[DataProvider('problematicIdentifierProvider')] + public function testCreateMappingWithProblematicIdentifiersAsserts(string $className): void { $this->expectException(MappingCreationException::class); $this->expectExceptionMessageMatches("/does not contain exactly one #\[Identifier\] attribute/"); - (new FlatMapper())->createMapping(RootDTOWithTooManyIdentifiers::class); + (new FlatMapper())->createMapping($className); + } + + /** + * @return iterable> + */ + public static function problematicIdentifierProvider(): iterable + { + yield [RootDTOWithTooManyIdentifiers::class]; + yield [RootDTOWithNoIdentifier::class]; } public function testCreateMappingWithReadonlyModifierOnNonScalarDtoAsserts(): void @@ -184,13 +195,6 @@ public function testCreateMappingWithReadonlyModifierOnScalarDtoSucceeds(): void (new FlatMapper())->createMapping(ScalarDTOWithReadonlyClassModifier::class); } - public function testCreateMappingWithNoIdentifierAsserts(): void - { - $this->expectException(MappingCreationException::class); - $this->expectExceptionMessageMatches("/does not contain exactly one #\[Identifier\] attribute/"); - (new FlatMapper())->createMapping(RootDTOWithNoIdentifier::class); - } - public function testCreateMappingWithNoConstructorAsserts(): void { $this->expectException(MappingCreationException::class); diff --git a/tests/FlatMapperTest.php b/tests/FlatMapperTest.php index a5f1574..22abb1a 100644 --- a/tests/FlatMapperTest.php +++ b/tests/FlatMapperTest.php @@ -12,6 +12,8 @@ use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Complex\CustomerDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Complex\InvoiceDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Complex\ProductDTO; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NamedArguments\NamedArgumentsChildDTO; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NamedArguments\NamedArgumentsParentDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NameTransformation\AccountDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NameTransformation\CarDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NameTransformation\ItemDTO; @@ -19,8 +21,6 @@ use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NameTransformation\OrderDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NameTransformation\PersonDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NameTransformation\ProductDTO as NameTransformationProductDTO; -use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NamedArguments\NamedArgumentsChildDTO; -use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NamedArguments\NamedArgumentsParentDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ReferenceArray\AuthorDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ReferenceArray\BookDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ScalarArray\ScalarArrayDTO; @@ -84,20 +84,6 @@ public function testMappingDataWithMissingForeignIdentifierPropertyAssertsWhenRo $flatMapper->map(AuthorDTO::class, $results); } - public function testMappingDataWithBadlyNamedPropertyAsserts(): void - { - $this->expectException(MappingException::class); - $this->expectExceptionMessageMatches('/Data does not contain required property: book_publisher_name/'); - - $results = [ - ['author_id' => 1, 'author_name' => 'Alice Brian', 'book_id' => 1, 'book_name' => 'Travelling as a group', 'badly_named_publisher_field' => 'TravelBooks'], - ['author_id' => 1, 'author_name' => 'Alice Brian', 'book_id' => 2, 'book_name' => 'My journeys', 'badly_named_publisher_field' => 'Lorem Press'], - ['author_id' => 2, 'author_name' => 'Bob Schmo', 'book_id' => 1, 'book_name' => 'Travelling as a group', 'badly_named_publisher_field' => 'TravelBooks'], - ]; - - ((new FlatMapper())->map(AuthorDTO::class, $results)); - } - public function testMappingDataWithMissingPropertyAsserts(): void { $this->expectException(MappingException::class); @@ -450,7 +436,7 @@ public function testMapWithNameTransformationCamelizeWhenDatasetNotSnakeCase(): { $this->expectException(MappingException::class); $this->expectExceptionMessageMatches('/Identifier not found: product_id/'); - + $results = [ ['ProductId' => 1, 'ProductName' => 'Widget', 'ProductPrice' => 19.99], ['ProductId' => 2, 'ProductName' => 'Gadget', 'ProductPrice' => 29.99], From 1dc0a30d367ee992b662a294097aa1d4077009c2 Mon Sep 17 00:00:00 2001 From: Renaud Date: Mon, 16 Feb 2026 14:57:42 +0100 Subject: [PATCH 13/14] Move mapping code in a MappingResolver class --- src/FlatMapper.php | 514 +-------------------- src/MappingResolver.php | 530 ++++++++++++++++++++++ tests/FlatMapperCreateMappingTest.php | 33 +- tests/FlatMapperTest.php | 2 + tests/PixelshapedFlatMapperBundleTest.php | 2 + 5 files changed, 578 insertions(+), 503 deletions(-) create mode 100644 src/MappingResolver.php diff --git a/src/FlatMapper.php b/src/FlatMapper.php index 2dbd191..2c821a7 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -3,29 +3,14 @@ namespace Pixelshaped\FlatMapperBundle; -use Error; use Pixelshaped\FlatMapperBundle\Exception\MappingCreationException; use Pixelshaped\FlatMapperBundle\Exception\MappingException; -use Pixelshaped\FlatMapperBundle\Mapping\Identifier; -use Pixelshaped\FlatMapperBundle\Mapping\NameTransformation; -use Pixelshaped\FlatMapperBundle\Mapping\ReferenceArray; -use Pixelshaped\FlatMapperBundle\Mapping\Scalar; -use Pixelshaped\FlatMapperBundle\Mapping\ScalarArray; -use ReflectionAttribute; -use ReflectionClass; use ReflectionProperty; use Symfony\Contracts\Cache\CacheInterface; use TypeError; -use function in_array; final class FlatMapper { - // Pre-compiled regex patterns for better performance - private const SNAKE_CASE_PATTERN_1 = '/([A-Z]+)([A-Z][a-z])/'; - private const SNAKE_CASE_PATTERN_2 = '/([a-z\d])([A-Z])/'; - private const SNAKE_CASE_REPLACEMENT = '\1_\2'; - private const MAPPING_NAMESPACE_PREFIX = 'Pixelshaped\\FlatMapperBundle\\Mapping\\'; - /** * @var array> */ @@ -34,16 +19,15 @@ final class FlatMapper * @var array>> */ private array $objectsMapping = []; - /** - * @var array, - * properties?: array> - * }> - */ - private array $yamlMappings = []; private ?CacheInterface $cacheService = null; - private bool $validateMapping = true; + + private MappingResolver $mappingResolver; + + public function __construct(?MappingResolver $mappingResolver = null) + { + $this->mappingResolver = $mappingResolver ?? new MappingResolver(); + } public function setCacheService(CacheInterface $cacheService): void { @@ -52,7 +36,7 @@ public function setCacheService(CacheInterface $cacheService): void public function setValidateMapping(bool $validateMapping): void { - $this->validateMapping = $validateMapping; + $this->mappingResolver->setValidateMapping($validateMapping); } /** @@ -63,7 +47,7 @@ public function setValidateMapping(bool $validateMapping): void */ public function setYamlMappings(array $yamlMappings): void { - $this->yamlMappings = $yamlMappings; + $this->mappingResolver->setYamlMappings($yamlMappings); // Mapping source changed, so in-memory compiled mappings need to be rebuilt. $this->objectIdentifiers = []; @@ -75,484 +59,22 @@ public function createMapping(string $dtoClassName): void if(!class_exists($dtoClassName)) { throw new MappingCreationException($dtoClassName.' is not a valid class name'); } - if(!isset($this->objectsMapping[$dtoClassName])) { - - if($this->cacheService !== null) { - $mappingInfo = $this->cacheService->get($this->createCacheKey($dtoClassName), function () use ($dtoClassName): array { - return $this->createMappingRecursive($dtoClassName); - }); - } else { - $mappingInfo = $this->createMappingRecursive($dtoClassName); - } - - $this->objectsMapping[$dtoClassName] = $mappingInfo['objectsMapping']; - $this->objectIdentifiers[$dtoClassName] = $mappingInfo['objectIdentifiers']; - } - } - - /** - * @param class-string $dtoClassName - * @param array|null $objectIdentifiers - * @param array>|null $objectsMapping - * @param list $mappingPath - * @return array{'objectIdentifiers': array, "objectsMapping": array>} - */ - private function createMappingRecursive(string $dtoClassName, ?array& $objectIdentifiers = null, ?array& $objectsMapping = null, array $mappingPath = []): array - { - if (in_array($dtoClassName, $mappingPath, true)) { - throw new MappingCreationException('Circular ReferenceArray mapping detected: ' . implode(' -> ', [...$mappingPath, $dtoClassName])); - } - - $mappingPath[] = $dtoClassName; - - if($objectIdentifiers === null) $objectIdentifiers = []; - if($objectsMapping === null) $objectsMapping = []; - - $objectIdentifiers = array_merge([$dtoClassName => 'RESERVED'], $objectIdentifiers); - - $reflectionClass = new ReflectionClass($dtoClassName); - - if (!$reflectionClass->isInstantiable()) { - throw new MappingCreationException('Class "' . $dtoClassName . '" is not instantiable.'); - } - - $constructor = $reflectionClass->getConstructor(); - - if($constructor === null) { - throw new MappingCreationException('Class "' . $dtoClassName . '" does not have a constructor.'); - } - - $identifiersCount = 0; - $transformation = null; - - foreach ($this->getClassMappingAttributes($reflectionClass) as $attribute) { - switch ($attribute['name']) { - case Identifier::class: - $identifierPropertyName = $this->getStringMappingArgument( - $attribute, - 'mappedPropertyName', - sprintf('class "%s"', $dtoClassName) - ); - if ($identifierPropertyName !== null && $identifierPropertyName !== '') { - $objectIdentifiers[$dtoClassName] = $identifierPropertyName; - $identifiersCount++; - } else { - throw new MappingCreationException('The Identifier attribute cannot be used without a property name when used as a Class attribute'); - } - break; - - case NameTransformation::class: - try { - /** @var NameTransformation $transformationInstance */ - $transformationInstance = $this->newMappingAttributeInstance($attribute); - $transformation = $transformationInstance; - } catch (Error $e) { - throw new MappingCreationException(sprintf( - 'Invalid NameTransformation attribute for %s:%s%s', - $dtoClassName, - PHP_EOL, - $e->getMessage() - )); - } - } - } - - foreach ($constructor->getParameters() as $reflectionParameter) { - $propertyName = $reflectionParameter->getName(); - $columnName = $transformation - ? $this->transformPropertyName($propertyName, $transformation) - : $propertyName; - $isIdentifier = false; - foreach ($this->getPropertyMappingAttributes($dtoClassName, $propertyName, $reflectionParameter->getAttributes()) as $attribute) { - if ($attribute['name'] === ReferenceArray::class || $attribute['name'] === ScalarArray::class) { - $mappingArgumentName = $attribute['name'] === ReferenceArray::class - ? 'referenceClassName' - : 'mappedPropertyName'; - $mappedProperty = $this->getStringMappingArgument( - $attribute, - $mappingArgumentName, - sprintf('property "%s::$%s"', $dtoClassName, $propertyName) - ); - - if($mappedProperty === null) { - if ($attribute['name'] === ReferenceArray::class) { - throw new MappingCreationException(sprintf( - 'Invalid ReferenceArray attribute for %s::$%s; requires a mapped value.', - $dtoClassName, - $propertyName - )); - } - throw new MappingCreationException(sprintf( - 'Invalid ScalarArray attribute for %s::$%s; requires a mapped value.', - $dtoClassName, - $propertyName - )); - } - - if($this->validateMapping) { - if((new ReflectionProperty($dtoClassName, $propertyName))->isReadOnly()) { - throw new MappingCreationException($reflectionClass->getName().': property '.$propertyName.' cannot be readonly as it is non-scalar and '.static::class.' needs to access it after object instantiation.'); - } - } - $objectsMapping[$dtoClassName][$propertyName] = $mappedProperty; - if($attribute['name'] === ReferenceArray::class) { - if(!class_exists($mappedProperty)) { - throw new MappingCreationException(sprintf( - 'Invalid reference class "%s" configured on property "%s::$%s". %s is not a valid class name.', - $mappedProperty, - $dtoClassName, - $propertyName, - $mappedProperty - )); - } - - /** @var class-string $mappedProperty */ - $this->createMappingRecursive($mappedProperty, $objectIdentifiers, $objectsMapping, $mappingPath); - } - continue 2; - } else if ($attribute['name'] === Identifier::class) { - $identifiersCount++; - $isIdentifier = true; - $identifierColumnName = $this->getStringMappingArgument( - $attribute, - 'mappedPropertyName', - sprintf('property "%s::$%s"', $dtoClassName, $propertyName) - ); - if($identifierColumnName === '') { - throw new MappingCreationException('Invalid Identifier attribute for '.$dtoClassName.'::$'.$propertyName); - } - if($identifierColumnName !== null) { - $columnName = $identifierColumnName; - } - } else if ($attribute['name'] === Scalar::class) { - $scalarColumnName = $this->getStringMappingArgument( - $attribute, - 'mappedPropertyName', - sprintf('property "%s::$%s"', $dtoClassName, $propertyName) - ); - if($scalarColumnName === null || $scalarColumnName === '') { - throw new MappingCreationException('Invalid Scalar attribute for '.$dtoClassName.'::$'.$propertyName); - } - $columnName = $scalarColumnName; - } - } - - if ($isIdentifier) { - $objectIdentifiers[$dtoClassName] = $columnName; - } - - $objectsMapping[$dtoClassName][$columnName] = null; - } - - if($this->validateMapping) { - if($identifiersCount !== 1) { - throw new MappingCreationException($dtoClassName.' does not contain exactly one #[Identifier] attribute.'); - } - - $uniqueCheck = []; - foreach ($objectIdentifiers as $key => $value) { - if (isset($uniqueCheck[$value])) { - throw new MappingCreationException('Several data identifiers are identical: ' . print_r($objectIdentifiers, true)); - } - $uniqueCheck[$value] = true; - } - } - - return [ - 'objectIdentifiers' => $objectIdentifiers, - 'objectsMapping' => $objectsMapping - ]; - } - - /** - * @param ReflectionAttribute $attribute - */ - private function getAttributeArgument(ReflectionAttribute $attribute, string $argumentName): mixed - { - $arguments = $attribute->getArguments(); - - if (array_key_exists($argumentName, $arguments)) { - return $arguments[$argumentName]; - } - - if (array_key_exists(0, $arguments)) { - return $arguments[0]; - } - - return null; - } - - private function transformPropertyName(string $propertyName, NameTransformation $transformation): string - { - if ($transformation->snakeCaseColumns) { - $propertyName = strtolower(preg_replace( - [self::SNAKE_CASE_PATTERN_1, self::SNAKE_CASE_PATTERN_2], - self::SNAKE_CASE_REPLACEMENT, - $propertyName - ) ?? $propertyName); - } - return $transformation->columnPrefix . $propertyName; - } - - /** - * Keep cache keys deterministic while invalidating when YAML mapping changes. - */ - private function createCacheKey(string $dtoClassName): string - { - $cacheKey = sha1($dtoClassName); - $mappingsHash = sha1(serialize($this->yamlMappings)); - - return 'pixelshaped_flat_mapper_'.$cacheKey.'_'.$mappingsHash; - } - - /** - * @param ReflectionClass $reflectionClass - * @return list, - * reflectionAttribute?: \ReflectionAttribute - * }> - */ - private function getClassMappingAttributes(ReflectionClass $reflectionClass): array - { - return $this->mergeMappingAttributes( - $reflectionClass->getName(), - $reflectionClass->getAttributes() - ); - } - - /** - * @param list<\ReflectionAttribute> $reflectionAttributes - * @return list, - * reflectionAttribute?: \ReflectionAttribute - * }> - */ - private function getPropertyMappingAttributes(string $dtoClassName, string $propertyName, array $reflectionAttributes): array - { - return $this->mergeMappingAttributes( - $dtoClassName, - $reflectionAttributes, - $propertyName - ); - } - - /** - * @param list<\ReflectionAttribute> $reflectionAttributes - * @return list, - * reflectionAttribute?: \ReflectionAttribute - * }> - */ - private function mergeMappingAttributes(string $dtoClassName, array $reflectionAttributes, ?string $propertyName = null): array - { - $mappingAttributes = []; - - foreach ($this->getYamlAttributes($dtoClassName, $propertyName) as $attributeName => $attributeArguments) { - $mappingAttributes[$attributeName] = [ - 'name' => $attributeName, - 'arguments' => $attributeArguments, - ]; - } - - foreach ($reflectionAttributes as $reflectionAttribute) { - $attributeName = $reflectionAttribute->getName(); - if (isset($mappingAttributes[$attributeName])) { - // Ensure reflection attributes override YAML and keep declaration ordering. - unset($mappingAttributes[$attributeName]); - } - $mappingAttributes[$attributeName] = [ - 'name' => $attributeName, - 'arguments' => $reflectionAttribute->getArguments(), - 'reflectionAttribute' => $reflectionAttribute, - ]; - } - - return array_values($mappingAttributes); - } - - /** - * @return array> - */ - private function getYamlAttributes(string $dtoClassName, ?string $propertyName = null): array - { - if (!isset($this->yamlMappings[$dtoClassName])) { - return []; - } - - $classMapping = $this->yamlMappings[$dtoClassName]; - if (!is_array($classMapping)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for class "%s". Expected an array.', - $dtoClassName - )); - } - - if ($propertyName === null) { - $rawAttributes = $classMapping['class'] ?? []; - return $this->normalizeYamlAttributeMap( - $rawAttributes, - sprintf('class "%s"', $dtoClassName) - ); - } - - $rawProperties = $classMapping['properties'] ?? []; - if (!is_array($rawProperties)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for class "%s". The "properties" section must be an array.', - $dtoClassName - )); - } - - $rawAttributes = $rawProperties[$propertyName] ?? []; - return $this->normalizeYamlAttributeMap( - $rawAttributes, - sprintf('property "%s::$%s"', $dtoClassName, $propertyName) - ); - } - - /** - * @return array> - */ - private function normalizeYamlAttributeMap(mixed $rawAttributes, string $mappingTarget): array - { - if ($rawAttributes === null) { - return []; - } - if (!is_array($rawAttributes)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for %s. Expected an attribute map array.', - $mappingTarget - )); + if(isset($this->objectsMapping[$dtoClassName])) { + return; } - $normalizedAttributes = []; - foreach ($rawAttributes as $attributeName => $rawArguments) { - if (!is_string($attributeName)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for %s. Attribute names must be strings.', - $mappingTarget - )); - } - - $resolvedAttributeName = $this->resolveAttributeClassName($attributeName, $mappingTarget); - $normalizedAttributes[$resolvedAttributeName] = $this->normalizeYamlAttributeArguments( - $rawArguments, - $attributeName, - $mappingTarget - ); - } - - return $normalizedAttributes; - } - - /** - * @return array - */ - private function normalizeYamlAttributeArguments(mixed $rawArguments, string $attributeName, string $mappingTarget): array - { - if ($rawArguments === null) { - return []; - } - - if (is_scalar($rawArguments)) { - return [$rawArguments]; - } - - if (is_array($rawArguments)) { - return $rawArguments; - } - - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for attribute "%s" on %s. Expected null, scalar, or array arguments.', - $attributeName, - $mappingTarget - )); - } - - /** - * @return class-string - */ - private function resolveAttributeClassName(string $attributeName, string $mappingTarget): string - { - $normalizedAttributeName = ltrim($attributeName, '\\'); - $className = str_contains($normalizedAttributeName, '\\') - ? $normalizedAttributeName - : self::MAPPING_NAMESPACE_PREFIX.$attributeName; - - if (!str_starts_with($className, self::MAPPING_NAMESPACE_PREFIX)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for %s. Unsupported mapping attribute class "%s".', - $mappingTarget, - $className - )); - } - - if (!class_exists($className)) { - throw new MappingCreationException(sprintf( - 'Invalid YAML mapping for %s. Attribute class "%s" does not exist.', - $mappingTarget, - $className - )); - } - - return $className; - } - - /** - * @param array{ - * name: class-string, - * arguments: array, - * reflectionAttribute?: \ReflectionAttribute - * } $attribute - */ - private function getStringMappingArgument(array $attribute, string $argumentName, string $mappingTarget): ?string - { - if (isset($attribute['reflectionAttribute'])) { - $argument = $this->getAttributeArgument($attribute['reflectionAttribute'], $argumentName); - } elseif (array_key_exists(0, $attribute['arguments'])) { - $argument = $attribute['arguments'][0]; - } elseif (array_key_exists($argumentName, $attribute['arguments'])) { - $argument = $attribute['arguments'][$argumentName]; + if($this->cacheService !== null) { + $mappingInfo = $this->cacheService->get($this->mappingResolver->createCacheKey($dtoClassName), function () use ($dtoClassName): array { + return $this->mappingResolver->resolve($dtoClassName); + }); } else { - $argument = null; + $mappingInfo = $this->mappingResolver->resolve($dtoClassName); } - if ($argument === null) { - return null; - } - - if (!is_string($argument)) { - throw new MappingCreationException(sprintf( - 'Invalid %s argument for attribute "%s" on %s. Expected string, got %s.', - $argumentName, - $attribute['name'], - $mappingTarget, - get_debug_type($argument) - )); - } - - return $argument; - } - - /** - * @param array{ - * name: class-string, - * arguments: array, - * reflectionAttribute?: \ReflectionAttribute - * } $attribute - */ - private function newMappingAttributeInstance(array $attribute): object - { - if (isset($attribute['reflectionAttribute'])) { - return $attribute['reflectionAttribute']->newInstance(); - } + $this->objectsMapping[$dtoClassName] = $mappingInfo['objectsMapping']; + $this->objectIdentifiers[$dtoClassName] = $mappingInfo['objectIdentifiers']; - $attributeClassName = $attribute['name']; - return new $attributeClassName(...$attribute['arguments']); } /** diff --git a/src/MappingResolver.php b/src/MappingResolver.php new file mode 100644 index 0000000..7ddbe86 --- /dev/null +++ b/src/MappingResolver.php @@ -0,0 +1,530 @@ +, + * properties?: array> + * }> + */ + private array $yamlMappings = []; + + public function setValidateMapping(bool $validateMapping): void + { + $this->validateMapping = $validateMapping; + } + + /** + * @param array, + * properties?: array> + * }> $yamlMappings + */ + public function setYamlMappings(array $yamlMappings): void + { + $this->yamlMappings = $yamlMappings; + } + + /** + * Keep cache keys deterministic while invalidating when YAML mapping changes. + */ + public function createCacheKey(string $dtoClassName): string + { + $cacheKey = sha1($dtoClassName); + $mappingsHash = sha1(serialize($this->yamlMappings)); + + return 'pixelshaped_flat_mapper_'.$cacheKey.'_'.$mappingsHash; + } + + /** + * @param string $dtoClassName + * @return array{'objectIdentifiers': array, "objectsMapping": array>} + */ + public function resolve(string $dtoClassName): array + { + if(!class_exists($dtoClassName)) { + throw new MappingCreationException($dtoClassName.' is not a valid class name'); + } + + /** @var class-string $dtoClassName */ + return $this->createMappingRecursive($dtoClassName); + } + + /** + * @param class-string $dtoClassName + * @param array|null $objectIdentifiers + * @param array>|null $objectsMapping + * @param list $mappingPath + * @return array{'objectIdentifiers': array, "objectsMapping": array>} + */ + private function createMappingRecursive(string $dtoClassName, ?array& $objectIdentifiers = null, ?array& $objectsMapping = null, array $mappingPath = []): array + { + if (in_array($dtoClassName, $mappingPath, true)) { + throw new MappingCreationException('Circular ReferenceArray mapping detected: ' . implode(' -> ', [...$mappingPath, $dtoClassName])); + } + + $mappingPath[] = $dtoClassName; + + if($objectIdentifiers === null) $objectIdentifiers = []; + if($objectsMapping === null) $objectsMapping = []; + + $objectIdentifiers = array_merge([$dtoClassName => 'RESERVED'], $objectIdentifiers); + + $reflectionClass = new ReflectionClass($dtoClassName); + + if (!$reflectionClass->isInstantiable()) { + throw new MappingCreationException('Class "' . $dtoClassName . '" is not instantiable.'); + } + + $constructor = $reflectionClass->getConstructor(); + + if($constructor === null) { + throw new MappingCreationException('Class "' . $dtoClassName . '" does not have a constructor.'); + } + + $identifiersCount = 0; + $transformation = null; + + foreach ($this->getClassMappingAttributes($reflectionClass) as $attribute) { + switch ($attribute['name']) { + case Identifier::class: + $identifierPropertyName = $this->getStringMappingArgument( + $attribute, + 'mappedPropertyName', + sprintf('class "%s"', $dtoClassName) + ); + if ($identifierPropertyName !== null && $identifierPropertyName !== '') { + $objectIdentifiers[$dtoClassName] = $identifierPropertyName; + $identifiersCount++; + } else { + throw new MappingCreationException('The Identifier attribute cannot be used without a property name when used as a Class attribute'); + } + break; + + case NameTransformation::class: + try { + /** @var NameTransformation $transformationInstance */ + $transformationInstance = $this->newMappingAttributeInstance($attribute); + $transformation = $transformationInstance; + } catch (Error $e) { + throw new MappingCreationException(sprintf( + 'Invalid NameTransformation attribute for %s:%s%s', + $dtoClassName, + PHP_EOL, + $e->getMessage() + )); + } + } + } + + foreach ($constructor->getParameters() as $reflectionParameter) { + $propertyName = $reflectionParameter->getName(); + $columnName = $transformation + ? $this->transformPropertyName($propertyName, $transformation) + : $propertyName; + $isIdentifier = false; + foreach ($this->getPropertyMappingAttributes($dtoClassName, $propertyName, $reflectionParameter->getAttributes()) as $attribute) { + if ($attribute['name'] === ReferenceArray::class || $attribute['name'] === ScalarArray::class) { + $mappingArgumentName = $attribute['name'] === ReferenceArray::class + ? 'referenceClassName' + : 'mappedPropertyName'; + $mappedProperty = $this->getStringMappingArgument( + $attribute, + $mappingArgumentName, + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + + if($mappedProperty === null) { + if ($attribute['name'] === ReferenceArray::class) { + throw new MappingCreationException(sprintf( + 'Invalid ReferenceArray attribute for %s::$%s; requires a mapped value.', + $dtoClassName, + $propertyName + )); + } + throw new MappingCreationException(sprintf( + 'Invalid ScalarArray attribute for %s::$%s; requires a mapped value.', + $dtoClassName, + $propertyName + )); + } + + if($this->validateMapping) { + if((new ReflectionProperty($dtoClassName, $propertyName))->isReadOnly()) { + throw new MappingCreationException($reflectionClass->getName().': property '.$propertyName.' cannot be readonly as it is non-scalar and '.static::class.' needs to access it after object instantiation.'); + } + } + $objectsMapping[$dtoClassName][$propertyName] = $mappedProperty; + if($attribute['name'] === ReferenceArray::class) { + if(!class_exists($mappedProperty)) { + throw new MappingCreationException(sprintf( + 'Invalid reference class "%s" configured on property "%s::$%s". %s is not a valid class name.', + $mappedProperty, + $dtoClassName, + $propertyName, + $mappedProperty + )); + } + + /** @var class-string $mappedProperty */ + $this->createMappingRecursive($mappedProperty, $objectIdentifiers, $objectsMapping, $mappingPath); + } + continue 2; + } else if ($attribute['name'] === Identifier::class) { + $identifiersCount++; + $isIdentifier = true; + $identifierColumnName = $this->getStringMappingArgument( + $attribute, + 'mappedPropertyName', + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + if($identifierColumnName === '') { + throw new MappingCreationException('Invalid Identifier attribute for '.$dtoClassName.'::$'.$propertyName); + } + if($identifierColumnName !== null) { + $columnName = $identifierColumnName; + } + } else if ($attribute['name'] === Scalar::class) { + $scalarColumnName = $this->getStringMappingArgument( + $attribute, + 'mappedPropertyName', + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + if($scalarColumnName === null || $scalarColumnName === '') { + throw new MappingCreationException('Invalid Scalar attribute for '.$dtoClassName.'::$'.$propertyName); + } + $columnName = $scalarColumnName; + } + } + + if ($isIdentifier) { + $objectIdentifiers[$dtoClassName] = $columnName; + } + + $objectsMapping[$dtoClassName][$columnName] = null; + } + + if($this->validateMapping) { + if($identifiersCount !== 1) { + throw new MappingCreationException($dtoClassName.' does not contain exactly one #[Identifier] attribute.'); + } + + $uniqueCheck = []; + foreach ($objectIdentifiers as $key => $value) { + if (isset($uniqueCheck[$value])) { + throw new MappingCreationException('Several data identifiers are identical: ' . print_r($objectIdentifiers, true)); + } + $uniqueCheck[$value] = true; + } + } + + return [ + 'objectIdentifiers' => $objectIdentifiers, + 'objectsMapping' => $objectsMapping + ]; + } + + /** + * @param ReflectionAttribute $attribute + */ + private function getAttributeArgument(ReflectionAttribute $attribute, string $argumentName): mixed + { + $arguments = $attribute->getArguments(); + + if (array_key_exists($argumentName, $arguments)) { + return $arguments[$argumentName]; + } + + if (array_key_exists(0, $arguments)) { + return $arguments[0]; + } + + return null; + } + + private function transformPropertyName(string $propertyName, NameTransformation $transformation): string + { + if ($transformation->snakeCaseColumns) { + $propertyName = strtolower(preg_replace( + [self::SNAKE_CASE_PATTERN_1, self::SNAKE_CASE_PATTERN_2], + self::SNAKE_CASE_REPLACEMENT, + $propertyName + ) ?? $propertyName); + } + return $transformation->columnPrefix . $propertyName; + } + + /** + * @param ReflectionClass $reflectionClass + * @return list, + * reflectionAttribute?: \ReflectionAttribute + * }> + */ + private function getClassMappingAttributes(ReflectionClass $reflectionClass): array + { + return $this->mergeMappingAttributes( + $reflectionClass->getName(), + $reflectionClass->getAttributes() + ); + } + + /** + * @param list<\ReflectionAttribute> $reflectionAttributes + * @return list, + * reflectionAttribute?: \ReflectionAttribute + * }> + */ + private function getPropertyMappingAttributes(string $dtoClassName, string $propertyName, array $reflectionAttributes): array + { + return $this->mergeMappingAttributes( + $dtoClassName, + $reflectionAttributes, + $propertyName + ); + } + + /** + * @param list<\ReflectionAttribute> $reflectionAttributes + * @return list, + * reflectionAttribute?: \ReflectionAttribute + * }> + */ + private function mergeMappingAttributes(string $dtoClassName, array $reflectionAttributes, ?string $propertyName = null): array + { + $mappingAttributes = []; + + foreach ($this->getYamlAttributes($dtoClassName, $propertyName) as $attributeName => $attributeArguments) { + $mappingAttributes[$attributeName] = [ + 'name' => $attributeName, + 'arguments' => $attributeArguments, + ]; + } + + foreach ($reflectionAttributes as $reflectionAttribute) { + $attributeName = $reflectionAttribute->getName(); + if (isset($mappingAttributes[$attributeName])) { + // Ensure reflection attributes override YAML and keep declaration ordering. + unset($mappingAttributes[$attributeName]); + } + $mappingAttributes[$attributeName] = [ + 'name' => $attributeName, + 'arguments' => $reflectionAttribute->getArguments(), + 'reflectionAttribute' => $reflectionAttribute, + ]; + } + + return array_values($mappingAttributes); + } + + /** + * @return array> + */ + private function getYamlAttributes(string $dtoClassName, ?string $propertyName = null): array + { + if (!isset($this->yamlMappings[$dtoClassName])) { + return []; + } + + $classMapping = $this->yamlMappings[$dtoClassName]; + if (!is_array($classMapping)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for class "%s". Expected an array.', + $dtoClassName + )); + } + + if ($propertyName === null) { + $rawAttributes = $classMapping['class'] ?? []; + return $this->normalizeYamlAttributeMap( + $rawAttributes, + sprintf('class "%s"', $dtoClassName) + ); + } + + $rawProperties = $classMapping['properties'] ?? []; + if (!is_array($rawProperties)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for class "%s". The "properties" section must be an array.', + $dtoClassName + )); + } + + $rawAttributes = $rawProperties[$propertyName] ?? []; + return $this->normalizeYamlAttributeMap( + $rawAttributes, + sprintf('property "%s::$%s"', $dtoClassName, $propertyName) + ); + } + + /** + * @return array> + */ + private function normalizeYamlAttributeMap(mixed $rawAttributes, string $mappingTarget): array + { + if ($rawAttributes === null) { + return []; + } + + if (!is_array($rawAttributes)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Expected an attribute map array.', + $mappingTarget + )); + } + + $normalizedAttributes = []; + foreach ($rawAttributes as $attributeName => $rawArguments) { + if (!is_string($attributeName)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Attribute names must be strings.', + $mappingTarget + )); + } + + $resolvedAttributeName = $this->resolveAttributeClassName($attributeName, $mappingTarget); + $normalizedAttributes[$resolvedAttributeName] = $this->normalizeYamlAttributeArguments( + $rawArguments, + $attributeName, + $mappingTarget + ); + } + + return $normalizedAttributes; + } + + /** + * @return array + */ + private function normalizeYamlAttributeArguments(mixed $rawArguments, string $attributeName, string $mappingTarget): array + { + if ($rawArguments === null) { + return []; + } + + if (is_scalar($rawArguments)) { + return [$rawArguments]; + } + + if (is_array($rawArguments)) { + return $rawArguments; + } + + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for attribute "%s" on %s. Expected null, scalar, or array arguments.', + $attributeName, + $mappingTarget + )); + } + + /** + * @return class-string + */ + private function resolveAttributeClassName(string $attributeName, string $mappingTarget): string + { + $normalizedAttributeName = ltrim($attributeName, '\\'); + $className = str_contains($normalizedAttributeName, '\\') + ? $normalizedAttributeName + : self::MAPPING_NAMESPACE_PREFIX.$attributeName; + + if (!str_starts_with($className, self::MAPPING_NAMESPACE_PREFIX)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Unsupported mapping attribute class "%s".', + $mappingTarget, + $className + )); + } + + if (!class_exists($className)) { + throw new MappingCreationException(sprintf( + 'Invalid YAML mapping for %s. Attribute class "%s" does not exist.', + $mappingTarget, + $className + )); + } + + return $className; + } + + /** + * @param array{ + * name: class-string, + * arguments: array, + * reflectionAttribute?: \ReflectionAttribute + * } $attribute + */ + private function getStringMappingArgument(array $attribute, string $argumentName, string $mappingTarget): ?string + { + if (isset($attribute['reflectionAttribute'])) { + $argument = $this->getAttributeArgument($attribute['reflectionAttribute'], $argumentName); + } elseif (array_key_exists(0, $attribute['arguments'])) { + $argument = $attribute['arguments'][0]; + } elseif (array_key_exists($argumentName, $attribute['arguments'])) { + $argument = $attribute['arguments'][$argumentName]; + } else { + $argument = null; + } + + if ($argument === null) { + return null; + } + + if (!is_string($argument)) { + throw new MappingCreationException(sprintf( + 'Invalid %s argument for attribute "%s" on %s. Expected string, got %s.', + $argumentName, + $attribute['name'], + $mappingTarget, + get_debug_type($argument) + )); + } + + return $argument; + } + + /** + * @param array{ + * name: class-string, + * arguments: array, + * reflectionAttribute?: \ReflectionAttribute + * } $attribute + */ + private function newMappingAttributeInstance(array $attribute): object + { + if (isset($attribute['reflectionAttribute'])) { + return $attribute['reflectionAttribute']->newInstance(); + } + + $attributeClassName = $attribute['name']; + return new $attributeClassName(...$attribute['arguments']); + } +} diff --git a/tests/FlatMapperCreateMappingTest.php b/tests/FlatMapperCreateMappingTest.php index 94f72dd..a1914fd 100644 --- a/tests/FlatMapperCreateMappingTest.php +++ b/tests/FlatMapperCreateMappingTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use Pixelshaped\FlatMapperBundle\Exception\MappingCreationException; use Pixelshaped\FlatMapperBundle\FlatMapper; +use Pixelshaped\FlatMapperBundle\MappingResolver; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\Circular\CycleRootDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\NameTransformation\InvalidNameTransformationDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootAbstractDTO; @@ -34,11 +35,12 @@ use Symfony\Contracts\Cache\ItemInterface; #[CoversMethod(FlatMapper::class, 'createMapping')] -#[CoversMethod(FlatMapper::class, 'createMappingRecursive')] -#[CoversMethod(FlatMapper::class, 'getAttributeArgument')] #[CoversMethod(FlatMapper::class, 'setCacheService')] +#[CoversMethod(FlatMapper::class, 'setValidateMapping')] #[CoversMethod(FlatMapper::class, 'setYamlMappings')] +#[CoversMethod(MappingResolver::class, 'resolve')] #[CoversClass(FlatMapper::class)] +#[CoversClass(MappingResolver::class)] #[CoversClass(MappingCreationException::class)] class FlatMapperCreateMappingTest extends TestCase { @@ -52,14 +54,15 @@ public function testCreateMappingWithValidDTOsDoesNotAssert(): void public function testCreateMappingWithCacheServiceDoesNotAssert(): void { $flatMapper = new FlatMapper(); + $mappingResolver = new MappingResolver(); // The intention is not to test the createMappingRecursive private method // but to dynamically give the CacheInterface mock a proper return value. - $reflectionMethod = (new \ReflectionClass(FlatMapper::class))->getMethod('createMappingRecursive'); + $reflectionMethod = (new \ReflectionClass(MappingResolver::class))->getMethod('createMappingRecursive'); $reflectionMethod->setAccessible(true); $cacheInterface = $this->createMock(CacheInterface::class); $cacheInterface->expects($this->once())->method('get')->willReturn( - $reflectionMethod->invoke($flatMapper, AuthorDTO::class) + $reflectionMethod->invoke($mappingResolver, AuthorDTO::class) ); $flatMapper->setCacheService($cacheInterface); @@ -158,6 +161,13 @@ public function testCreateMappingWrongClassNameAsserts(): void (new FlatMapper())->createMapping('ThisIsNotAValidClassString'); } + public function testResolveWrongClassNameAsserts(): void + { + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches("/An error occurred during mapping creation: ThisIsNotAValidClassString is not a valid class name/"); + (new MappingResolver())->resolve('ThisIsNotAValidClassString'); + } + public function testCreateMappingWithSeveralIdenticalIdentifiersAsserts(): void { $this->expectException(MappingCreationException::class); @@ -189,6 +199,15 @@ public function testCreateMappingWithReadonlyModifierOnNonScalarDtoAsserts(): vo (new FlatMapper())->createMapping(RootDTOWithReadonlyClassModifier::class); } + public function testCreateMappingWithValidationDisabledDoesNotAssert(): void + { + $flatMapper = new FlatMapper(); + $flatMapper->setValidateMapping(false); + + $this->expectNotToPerformAssertions(); + $flatMapper->createMapping(RootDTOWithNoIdentifier::class); + } + public function testCreateMappingWithReadonlyModifierOnScalarDtoSucceeds(): void { $this->expectNotToPerformAssertions(); @@ -534,12 +553,12 @@ public function testCreateMappingWithYamlScalarNonStringArgumentAsserts(): void public function testNormalizeYamlAttributeMapWithNullReturnsEmptyArray(): void { - $flatMapper = new FlatMapper(); - $reflectionMethod = (new \ReflectionClass(FlatMapper::class))->getMethod('normalizeYamlAttributeMap'); + $mappingResolver = new MappingResolver(); + $reflectionMethod = (new \ReflectionClass(MappingResolver::class))->getMethod('normalizeYamlAttributeMap'); $reflectionMethod->setAccessible(true); /** @var array> $result */ - $result = $reflectionMethod->invoke($flatMapper, null, 'class "Foo\\Bar\\Baz"'); + $result = $reflectionMethod->invoke($mappingResolver, null, 'class "Foo\\Bar\\Baz"'); $this->assertSame([], $result); } } diff --git a/tests/FlatMapperTest.php b/tests/FlatMapperTest.php index 22abb1a..3742c26 100644 --- a/tests/FlatMapperTest.php +++ b/tests/FlatMapperTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Pixelshaped\FlatMapperBundle\Exception\MappingException; use Pixelshaped\FlatMapperBundle\FlatMapper; +use Pixelshaped\FlatMapperBundle\MappingResolver; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ClassAttributes\AuthorDTO as ClassAttributesAuthorDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ClassAttributes\BookDTO as ClassAttributesBookDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Complex\CustomerDTO; @@ -29,6 +30,7 @@ use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Yaml\BookDTO as YamlBookDTO; #[CoversClass(FlatMapper::class)] +#[CoversClass(MappingResolver::class)] #[CoversClass(MappingException::class)] class FlatMapperTest extends TestCase { diff --git a/tests/PixelshapedFlatMapperBundleTest.php b/tests/PixelshapedFlatMapperBundleTest.php index 75f45de..4bcbd37 100644 --- a/tests/PixelshapedFlatMapperBundleTest.php +++ b/tests/PixelshapedFlatMapperBundleTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Pixelshaped\FlatMapperBundle\FlatMapper; +use Pixelshaped\FlatMapperBundle\MappingResolver; use Pixelshaped\FlatMapperBundle\PixelshapedFlatMapperBundle; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\WithoutAttributeDTO; use RecursiveDirectoryIterator; @@ -26,6 +27,7 @@ use Symfony\Contracts\Cache\CacheInterface; #[CoversClass(FlatMapper::class)] +#[CoversClass(MappingResolver::class)] #[CoversClass(PixelshapedFlatMapperBundle::class)] class PixelshapedFlatMapperBundleTest extends TestCase { From 7a7b89031324de3bc1f026089a064bac03906f54 Mon Sep 17 00:00:00 2001 From: Renaud Date: Mon, 16 Feb 2026 15:26:29 +0100 Subject: [PATCH 14/14] No need for parametrized constructor --- src/FlatMapper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FlatMapper.php b/src/FlatMapper.php index 2c821a7..e25b692 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -24,9 +24,9 @@ final class FlatMapper private MappingResolver $mappingResolver; - public function __construct(?MappingResolver $mappingResolver = null) + public function __construct() { - $this->mappingResolver = $mappingResolver ?? new MappingResolver(); + $this->mappingResolver = new MappingResolver(); } public function setCacheService(CacheInterface $cacheService): void