diff --git a/README.md b/README.md index 5e6ad30..5fd3b07 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 @@ -399,6 +438,16 @@ $flatMapper = new FlatMapper(); $flatMapper->setCacheService($cache); // Any Symfony\Contracts\Cache\CacheInterface implementation $flatMapper->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/src/FlatMapper.php b/src/FlatMapper.php index 3430f2a..e25b692 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -3,28 +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'; - /** * @var array> */ @@ -35,7 +21,13 @@ final class FlatMapper private array $objectsMapping = []; private ?CacheInterface $cacheService = null; - private bool $validateMapping = true; + + private MappingResolver $mappingResolver; + + public function __construct() + { + $this->mappingResolver = new MappingResolver(); + } public function setCacheService(CacheInterface $cacheService): void { @@ -44,198 +36,45 @@ public function setCacheService(CacheInterface $cacheService): void public function setValidateMapping(bool $validateMapping): void { - $this->validateMapping = $validateMapping; - } - - 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) { - $cacheKey = sha1($dtoClassName); - $mappingInfo = $this->cacheService->get('pixelshaped_flat_mapper_'.$cacheKey, function () use ($dtoClassName): array { - return $this->createMappingRecursive($dtoClassName); - }); - } else { - $mappingInfo = $this->createMappingRecursive($dtoClassName); - } - - $this->objectsMapping[$dtoClassName] = $mappingInfo['objectsMapping']; - $this->objectIdentifiers[$dtoClassName] = $mappingInfo['objectIdentifiers']; - } + $this->mappingResolver->setValidateMapping($validateMapping); } /** - * @param class-string $dtoClassName - * @param array|null $objectIdentifiers - * @param array>|null $objectsMapping - * @param list $mappingPath - * @return array{'objectIdentifiers': array, "objectsMapping": array>} + * @param array, + * properties?: array> + * }> $yamlMappings */ - private function createMappingRecursive(string $dtoClassName, ?array& $objectIdentifiers = null, ?array& $objectsMapping = null, array $mappingPath = []): array + public function setYamlMappings(array $yamlMappings): void { - if (in_array($dtoClassName, $mappingPath, true)) { - throw new MappingCreationException('Circular ReferenceArray mapping detected: ' . implode(' -> ', [...$mappingPath, $dtoClassName])); - } + $this->mappingResolver->setYamlMappings($yamlMappings); - $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 ($reflectionClass->getAttributes() as $attribute) { - switch ($attribute->getName()) { - case Identifier::class: - $mappedPropertyName = $this->getAttributeArgument($attribute, 'mappedPropertyName'); - if ($mappedPropertyName !== null && $mappedPropertyName !== '') { - $objectIdentifiers[$dtoClassName] = $mappedPropertyName; - $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 $transformation */ - $transformation = $attribute->newInstance(); - } 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 ($reflectionParameter->getAttributes() as $attribute) { - if ($attribute->getName() === ReferenceArray::class || $attribute->getName() === ScalarArray::class) { - 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.'); - } - } - if($attribute->getName() === ReferenceArray::class) { - $referenceClassName = $this->getAttributeArgument($attribute, 'referenceClassName'); - if($referenceClassName === null || $referenceClassName === '') { - throw new MappingCreationException('Invalid ReferenceArray attribute for '.$dtoClassName.'::$'.$propertyName); - } - if(!class_exists($referenceClassName)) { - throw new MappingCreationException($referenceClassName.' is not a valid class name'); - } - /** @var class-string $referenceClassName */ - $objectsMapping[$dtoClassName][$propertyName] = $referenceClassName; - $this->createMappingRecursive($referenceClassName, $objectIdentifiers, $objectsMapping, $mappingPath); - } else { - $mappedPropertyName = $this->getAttributeArgument($attribute, 'mappedPropertyName'); - if($mappedPropertyName === null || $mappedPropertyName === '') { - throw new MappingCreationException('Invalid ScalarArray attribute for '.$dtoClassName.'::$'.$propertyName); - } - $objectsMapping[$dtoClassName][$propertyName] = $mappedPropertyName; - } - continue 2; - } else if ($attribute->getName() === Identifier::class) { - $identifiersCount++; - $isIdentifier = true; - $mappedPropertyName = $this->getAttributeArgument($attribute, 'mappedPropertyName'); - if($mappedPropertyName === '') { - throw new MappingCreationException('Invalid Identifier attribute for '.$dtoClassName.'::$'.$propertyName); - } - if($mappedPropertyName !== null) { - $columnName = $mappedPropertyName; - } - } else if ($attribute->getName() === Scalar::class) { - $mappedPropertyName = $this->getAttributeArgument($attribute, 'mappedPropertyName'); - if($mappedPropertyName === null || $mappedPropertyName === '') { - throw new MappingCreationException('Invalid Scalar attribute for '.$dtoClassName.'::$'.$propertyName); - } - $columnName = $mappedPropertyName; - } - } - - 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 - ]; + // Mapping source changed, so in-memory compiled mappings need to be rebuilt. + $this->objectIdentifiers = []; + $this->objectsMapping = []; } - /** - * @param ReflectionAttribute $attribute - */ - private function getAttributeArgument(ReflectionAttribute $attribute, string $argumentName): ?string + public function createMapping(string $dtoClassName): void { - $arguments = $attribute->getArguments(); + if(!class_exists($dtoClassName)) { + throw new MappingCreationException($dtoClassName.' is not a valid class name'); + } - if (array_key_exists($argumentName, $arguments)) { - return $arguments[$argumentName] === null ? null : (string)$arguments[$argumentName]; + if(isset($this->objectsMapping[$dtoClassName])) { + return; } - if (array_key_exists(0, $arguments)) { - return $arguments[0] === null ? null : (string)$arguments[0]; + if($this->cacheService !== null) { + $mappingInfo = $this->cacheService->get($this->mappingResolver->createCacheKey($dtoClassName), function () use ($dtoClassName): array { + return $this->mappingResolver->resolve($dtoClassName); + }); + } else { + $mappingInfo = $this->mappingResolver->resolve($dtoClassName); } - return null; - } + $this->objectsMapping[$dtoClassName] = $mappingInfo['objectsMapping']; + $this->objectIdentifiers[$dtoClassName] = $mappingInfo['objectIdentifiers']; - 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; } /** 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/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/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 @@ + $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 @@ +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); @@ -74,6 +84,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); @@ -81,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); @@ -88,11 +175,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 @@ -102,17 +199,19 @@ public function testCreateMappingWithReadonlyModifierOnNonScalarDtoAsserts(): vo (new FlatMapper())->createMapping(RootDTOWithReadonlyClassModifier::class); } - public function testCreateMappingWithReadonlyModifierOnScalarDtoSucceeds(): void + public function testCreateMappingWithValidationDisabledDoesNotAssert(): void { + $flatMapper = new FlatMapper(); + $flatMapper->setValidateMapping(false); + $this->expectNotToPerformAssertions(); - (new FlatMapper())->createMapping(ScalarDTOWithReadonlyClassModifier::class); + $flatMapper->createMapping(RootDTOWithNoIdentifier::class); } - public function testCreateMappingWithNoIdentifierAsserts(): void + public function testCreateMappingWithReadonlyModifierOnScalarDtoSucceeds(): void { - $this->expectException(MappingCreationException::class); - $this->expectExceptionMessageMatches("/does not contain exactly one #\[Identifier\] attribute/"); - (new FlatMapper())->createMapping(RootDTOWithNoIdentifier::class); + $this->expectNotToPerformAssertions(); + (new FlatMapper())->createMapping(ScalarDTOWithReadonlyClassModifier::class); } public function testCreateMappingWithNoConstructorAsserts(): void @@ -191,4 +290,275 @@ public function testCreateMappingWithInvalidScalarAttributeAsserts(): void $this->expectExceptionMessageMatches('/Invalid Scalar attribute/'); (new FlatMapper())->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(); + $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 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(); + // @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 + { + $mappingResolver = new MappingResolver(); + $reflectionMethod = (new \ReflectionClass(MappingResolver::class))->getMethod('normalizeYamlAttributeMap'); + $reflectionMethod->setAccessible(true); + + /** @var array> $result */ + $result = $reflectionMethod->invoke($mappingResolver, null, 'class "Foo\\Bar\\Baz"'); + $this->assertSame([], $result); + } } diff --git a/tests/FlatMapperTest.php b/tests/FlatMapperTest.php index ac5d9ae..3742c26 100644 --- a/tests/FlatMapperTest.php +++ b/tests/FlatMapperTest.php @@ -7,11 +7,14 @@ 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; 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,14 +22,15 @@ 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; 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(MappingResolver::class)] #[CoversClass(MappingException::class)] class FlatMapperTest extends TestCase { @@ -82,20 +86,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); @@ -265,6 +255,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, [])); @@ -373,7 +438,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], diff --git a/tests/Functional/BundleFunctionalTest.php b/tests/Functional/BundleFunctionalTest.php deleted file mode 100644 index b587fe8..0000000 --- a/tests/Functional/BundleFunctionalTest.php +++ /dev/null @@ -1,128 +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); - } -} - -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 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 new file mode 100644 index 0000000..4bcbd37 --- /dev/null +++ b/tests/PixelshapedFlatMapperBundleTest.php @@ -0,0 +1,223 @@ + [ + '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 + ); + } + + 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 = []; + $projectDirectory = dirname(__DIR__); + $bundlePath = $projectDirectory.'/src/PixelshapedFlatMapperBundle.php'; + + return new ContainerConfigurator( + $containerBuilder, + new PhpFileLoader($containerBuilder, new FileLocator([$projectDirectory])), + $instanceof, + $bundlePath, + $bundlePath, + '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 + { + /** @phpstan-ignore-next-line */ + return $callback(); + } + + public function delete(string $key): bool + { + return true; + } +}