From 171edf7fcb63d314480d074e7aa7e4fd5cecb826 Mon Sep 17 00:00:00 2001 From: Renaud Date: Sun, 15 Feb 2026 23:02:12 +0100 Subject: [PATCH 1/7] Patch dedup issue --- src/FlatMapper.php | 52 ++++++++++++++++++++++++---------------- tests/FlatMapperTest.php | 14 +++++++++++ 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/FlatMapper.php b/src/FlatMapper.php index 10326b2..cfb639c 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -155,7 +155,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])) { @@ -200,32 +200,42 @@ public function map(string $dtoClassName, iterable $data): array { 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) { - if($foreignObjectClassOrIdentifier !== null) { - if (isset($this->objectsMapping[$dtoClassName][$foreignObjectClassOrIdentifier])) { - // Handles ReferenceArray attribute - $foreignIdentifier = $this->objectIdentifiers[$dtoClassName][$foreignObjectClassOrIdentifier]; - if($row[$foreignIdentifier] !== null) { - $referencesMap[$objectClass][$row[$identifier]][$objectProperty][$row[$foreignIdentifier]] = $objectsMap[$foreignObjectClassOrIdentifier][$row[$foreignIdentifier]]; - } - } else { - // Handles ScalarArray attribute - if($row[$foreignObjectClassOrIdentifier] !== null) { - $referencesMap[$objectClass][$row[$identifier]][$objectProperty][] = $row[$foreignObjectClassOrIdentifier]; - } + if ($row[$identifier] === null) { + continue; + } + + $objectIdentifier = $row[$identifier]; + $isNewObject = !isset($objectsMap[$objectClass][$objectIdentifier]); + $constructorValues = []; + + foreach ($this->objectsMapping[$dtoClassName][$objectClass] as $objectProperty => $foreignObjectClassOrIdentifier) { + if($foreignObjectClassOrIdentifier !== null) { + if (isset($this->objectsMapping[$dtoClassName][$foreignObjectClassOrIdentifier])) { + // Handles ReferenceArray attribute + $foreignIdentifier = $this->objectIdentifiers[$dtoClassName][$foreignObjectClassOrIdentifier]; + if($row[$foreignIdentifier] !== null) { + $referencesMap[$objectClass][$objectIdentifier][$objectProperty][$row[$foreignIdentifier]] = $objectsMap[$foreignObjectClassOrIdentifier][$row[$foreignIdentifier]]; } - $constructorValues[] = []; } else { - if(!array_key_exists($objectProperty, $row)) { - throw new MappingException('Data does not contain required property: ' . $objectProperty); + // Handles ScalarArray attribute + if($row[$foreignObjectClassOrIdentifier] !== null) { + $referencesMap[$objectClass][$objectIdentifier][$objectProperty][] = $row[$foreignObjectClassOrIdentifier]; } - $constructorValues[] = $row[$objectProperty]; } + if ($isNewObject) { + $constructorValues[] = []; + } + } else if ($isNewObject) { + if(!array_key_exists($objectProperty, $row)) { + throw new MappingException('Data does not contain required property: ' . $objectProperty); + } + $constructorValues[] = $row[$objectProperty]; } + } + + if ($isNewObject) { try { - $objectsMap[$objectClass][$row[$identifier]] = new $objectClass(...$constructorValues); + $objectsMap[$objectClass][$objectIdentifier] = new $objectClass(...$constructorValues); } catch (\TypeError $e) { throw new MappingException('Cannot construct object: '.$e->getMessage()); } diff --git a/tests/FlatMapperTest.php b/tests/FlatMapperTest.php index 71e4566..a81a883 100644 --- a/tests/FlatMapperTest.php +++ b/tests/FlatMapperTest.php @@ -122,6 +122,20 @@ public function testMapValidNestedDTOs(): void ); } + public function testMapDoesNotRecreateObjectForDuplicateIdentifierRows(): void + { + $results = [ + ['author_id' => 1, 'author_name' => 'Alice First', 'book_id' => 1, 'book_name' => 'Book A', 'book_publisher_name' => 'Pub A'], + ['author_id' => 1, 'author_name' => 'Alice Overwritten', 'book_id' => 2, 'book_name' => 'Book B', 'book_publisher_name' => 'Pub B'], + ]; + + $flatMapperResults = ((new FlatMapper())->map(AuthorDTO::class, $results)); + + $this->assertArrayHasKey(1, $flatMapperResults); + $this->assertSame('Alice First', $flatMapperResults[1]->name); + $this->assertCount(2, $flatMapperResults[1]->books); + } + public function testMapValidScalarArrayDTO(): void { $results = [ From a9d5efc67453529bac5e7ee9638963c0cdf65192 Mon Sep 17 00:00:00 2001 From: Renaud Date: Sun, 15 Feb 2026 23:11:32 +0100 Subject: [PATCH 2/7] Fix potential cache key collision --- src/FlatMapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FlatMapper.php b/src/FlatMapper.php index cfb639c..d1a0634 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -52,7 +52,7 @@ public function createMapping(string $dtoClassName): void if(!isset($this->objectsMapping[$dtoClassName])) { if($this->cacheService !== null) { - $cacheKey = strtr($dtoClassName, ['\\' => '_', '-' => '_', ' ' => '_']); + $cacheKey = sha1($dtoClassName); $mappingInfo = $this->cacheService->get('pixelshaped_flat_mapper_'.$cacheKey, function () use ($dtoClassName): array { return $this->createMappingRecursive($dtoClassName); }); From 4f701ab0cb43041336167c5349e0c98c000f89c0 Mon Sep 17 00:00:00 2001 From: Renaud Date: Sun, 15 Feb 2026 23:13:31 +0100 Subject: [PATCH 3/7] Fix readme --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 330bf56..5e6ad30 100644 --- a/README.md +++ b/README.md @@ -396,9 +396,8 @@ use Pixelshaped\FlatMapperBundle\FlatMapper; $flatMapper = new FlatMapper(); // Optional: configure for production -$flatMapper - ->setCacheService($psr6CachePool) // Any PSR-6 cache - ->setValidateMapping(false); // Skip validation checks +$flatMapper->setCacheService($cache); // Any Symfony\Contracts\Cache\CacheInterface implementation +$flatMapper->setValidateMapping(false); // Skip validation checks $result = $flatMapper->map(AuthorDTO::class, $queryResults); ``` From aac6d63ae00627d209f047cb9336f1e25eb55b2b Mon Sep 17 00:00:00 2001 From: Renaud Date: Sun, 15 Feb 2026 23:18:27 +0100 Subject: [PATCH 4/7] Handle circular dependency with an error --- src/FlatMapper.php | 15 ++++++++++--- .../Invalid/Circular/CycleLeafDTO.php | 21 +++++++++++++++++++ .../Invalid/Circular/CycleRootDTO.php | 21 +++++++++++++++++++ tests/FlatMapperCreateMappingTest.php | 8 +++++++ 4 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 tests/Examples/Invalid/Circular/CycleLeafDTO.php create mode 100644 tests/Examples/Invalid/Circular/CycleRootDTO.php diff --git a/src/FlatMapper.php b/src/FlatMapper.php index d1a0634..8c63630 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -14,6 +14,8 @@ use ReflectionClass; use ReflectionProperty; use Symfony\Contracts\Cache\CacheInterface; +use TypeError; +use function in_array; final class FlatMapper { @@ -69,10 +71,17 @@ public function createMapping(string $dtoClassName): void * @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 + 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 = []; @@ -130,7 +139,7 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde } $objectsMapping[$dtoClassName][$propertyName] = (string)$attribute->getArguments()[0]; if($attribute->getName() === ReferenceArray::class) { - $this->createMappingRecursive($attribute->getArguments()[0], $objectIdentifiers, $objectsMapping); + $this->createMappingRecursive($attribute->getArguments()[0], $objectIdentifiers, $objectsMapping, $mappingPath); } continue 2; } else if ($attribute->getName() === Identifier::class) { @@ -236,7 +245,7 @@ public function map(string $dtoClassName, iterable $data): array { if ($isNewObject) { try { $objectsMap[$objectClass][$objectIdentifier] = new $objectClass(...$constructorValues); - } catch (\TypeError $e) { + } catch (TypeError $e) { throw new MappingException('Cannot construct object: '.$e->getMessage()); } } diff --git a/tests/Examples/Invalid/Circular/CycleLeafDTO.php b/tests/Examples/Invalid/Circular/CycleLeafDTO.php new file mode 100644 index 0000000..6e45af6 --- /dev/null +++ b/tests/Examples/Invalid/Circular/CycleLeafDTO.php @@ -0,0 +1,21 @@ + $roots + */ + public function __construct( + #[Identifier] + public int $leafId, + #[ReferenceArray(CycleRootDTO::class)] + public array $roots + ) { + } +} diff --git a/tests/Examples/Invalid/Circular/CycleRootDTO.php b/tests/Examples/Invalid/Circular/CycleRootDTO.php new file mode 100644 index 0000000..a3db76f --- /dev/null +++ b/tests/Examples/Invalid/Circular/CycleRootDTO.php @@ -0,0 +1,21 @@ + $leafs + */ + public function __construct( + #[Identifier] + public int $rootId, + #[ReferenceArray(CycleLeafDTO::class)] + public array $leafs + ) { + } +} diff --git a/tests/FlatMapperCreateMappingTest.php b/tests/FlatMapperCreateMappingTest.php index 0b82594..955c2b0 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\Tests\Examples\Invalid\Circular\CycleRootDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\NameTransformation\InvalidNameTransformationDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTO as InvalidRootDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithEmptyClassIdentifier; @@ -126,4 +127,11 @@ public function testCreateMappingWithInvalidNameTransformationAsserts(): void $this->expectExceptionMessageMatches("/Invalid NameTransformation attribute/"); (new FlatMapper())->createMapping(InvalidNameTransformationDTO::class); } + + public function testCreateMappingWithCircularReferenceArrayAsserts(): void + { + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Circular ReferenceArray mapping detected/'); + (new FlatMapper())->createMapping(CycleRootDTO::class); + } } From b3860db9b252412ab2a86908cee23b37dd39fe4e Mon Sep 17 00:00:00 2001 From: Renaud Date: Sun, 15 Feb 2026 23:37:10 +0100 Subject: [PATCH 5/7] Fix some more mapping issues --- src/FlatMapper.php | 56 ++++++++++++++++--- ...tDTOWithInvalidReferenceArrayAttribute.php | 23 ++++++++ .../RootDTOWithInvalidReferenceArrayClass.php | 23 ++++++++ ...RootDTOWithInvalidScalarArrayAttribute.php | 23 ++++++++ .../RootDTOWithInvalidScalarAttribute.php | 17 ++++++ .../NamedArguments/NamedArgumentsChildDTO.php | 17 ++++++ .../NamedArgumentsParentDTO.php | 26 +++++++++ tests/FlatMapperCreateMappingTest.php | 33 +++++++++++ tests/FlatMapperTest.php | 38 +++++++++++++ tests/Functional/BundleFunctionalTest.php | 26 +++++++++ 10 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 tests/Examples/Invalid/RootDTOWithInvalidReferenceArrayAttribute.php create mode 100644 tests/Examples/Invalid/RootDTOWithInvalidReferenceArrayClass.php create mode 100644 tests/Examples/Invalid/RootDTOWithInvalidScalarArrayAttribute.php create mode 100644 tests/Examples/Invalid/RootDTOWithInvalidScalarAttribute.php create mode 100644 tests/Examples/Valid/NamedArguments/NamedArgumentsChildDTO.php create mode 100644 tests/Examples/Valid/NamedArguments/NamedArgumentsParentDTO.php diff --git a/src/FlatMapper.php b/src/FlatMapper.php index 8c63630..64d92bd 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -11,6 +11,7 @@ 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; @@ -101,8 +102,9 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde foreach ($reflectionClass->getAttributes() as $attribute) { switch ($attribute->getName()) { case Identifier::class: - if (isset($attribute->getArguments()[0]) && $attribute->getArguments()[0] !== null) { - $objectIdentifiers[$dtoClassName] = $attribute->getArguments()[0]; + $mappedPropertyName = $this->getAttributeArgument($attribute, 'mappedPropertyName'); + if ($mappedPropertyName !== null) { + $objectIdentifiers[$dtoClassName] = $mappedPropertyName; $identifiersCount++; } else { throw new MappingCreationException('The Identifier attribute cannot be used without a property name when used as a Class attribute'); @@ -137,19 +139,38 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde 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, $mappingPath); + $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; - if(isset($attribute->getArguments()[0]) && $attribute->getArguments()[0] !== null) { - $columnName = $attribute->getArguments()[0]; + $mappedPropertyName = $this->getAttributeArgument($attribute, 'mappedPropertyName'); + if($mappedPropertyName !== null) { + $columnName = $mappedPropertyName; } } else if ($attribute->getName() === Scalar::class) { - $columnName = $attribute->getArguments()[0]; + $mappedPropertyName = $this->getAttributeArgument($attribute, 'mappedPropertyName'); + if($mappedPropertyName === null || $mappedPropertyName === '') { + throw new MappingCreationException('Invalid Scalar attribute for '.$dtoClassName.'::$'.$propertyName); + } + $columnName = $mappedPropertyName; } } @@ -180,6 +201,24 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde ]; } + /** + * @param ReflectionAttribute $attribute + */ + private function getAttributeArgument(ReflectionAttribute $attribute, string $argumentName): ?string + { + $arguments = $attribute->getArguments(); + + if (array_key_exists($argumentName, $arguments)) { + return $arguments[$argumentName] === null ? null : (string)$arguments[$argumentName]; + } + + if (array_key_exists(0, $arguments)) { + return $arguments[0] === null ? null : (string)$arguments[0]; + } + + return null; + } + private function transformPropertyName(string $propertyName, NameTransformation $transformation): string { if ($transformation->snakeCaseColumns) { @@ -227,6 +266,9 @@ public function map(string $dtoClassName, iterable $data): array { } } else { // Handles ScalarArray attribute + if(!array_key_exists($foreignObjectClassOrIdentifier, $row)) { + throw new MappingException('Data does not contain required property: ' . $foreignObjectClassOrIdentifier); + } if($row[$foreignObjectClassOrIdentifier] !== null) { $referencesMap[$objectClass][$objectIdentifier][$objectProperty][] = $row[$foreignObjectClassOrIdentifier]; } diff --git a/tests/Examples/Invalid/RootDTOWithInvalidReferenceArrayAttribute.php b/tests/Examples/Invalid/RootDTOWithInvalidReferenceArrayAttribute.php new file mode 100644 index 0000000..6caac57 --- /dev/null +++ b/tests/Examples/Invalid/RootDTOWithInvalidReferenceArrayAttribute.php @@ -0,0 +1,23 @@ + $children + */ + public function __construct( + #[Identifier] + #[Scalar('object1_id')] + public int $id, + // @phpstan-ignore-next-line + #[ReferenceArray(invalidArgumentName: LeafDTO::class)] + public array $children, + ) {} +} diff --git a/tests/Examples/Invalid/RootDTOWithInvalidReferenceArrayClass.php b/tests/Examples/Invalid/RootDTOWithInvalidReferenceArrayClass.php new file mode 100644 index 0000000..abb833f --- /dev/null +++ b/tests/Examples/Invalid/RootDTOWithInvalidReferenceArrayClass.php @@ -0,0 +1,23 @@ + $children + */ + public function __construct( + #[Identifier] + #[Scalar('object1_id')] + public int $id, + // @phpstan-ignore-next-line + #[ReferenceArray(referenceClassName: 'This\\Class\\Does\\Not\\Exist')] + public array $children, + ) {} +} diff --git a/tests/Examples/Invalid/RootDTOWithInvalidScalarArrayAttribute.php b/tests/Examples/Invalid/RootDTOWithInvalidScalarArrayAttribute.php new file mode 100644 index 0000000..b0b2b42 --- /dev/null +++ b/tests/Examples/Invalid/RootDTOWithInvalidScalarArrayAttribute.php @@ -0,0 +1,23 @@ + $children + */ + public function __construct( + #[Identifier] + #[Scalar('object1_id')] + public int $id, + // @phpstan-ignore-next-line + #[ScalarArray(invalidArgumentName: 'object2_id')] + public array $children, + ) {} +} diff --git a/tests/Examples/Invalid/RootDTOWithInvalidScalarAttribute.php b/tests/Examples/Invalid/RootDTOWithInvalidScalarAttribute.php new file mode 100644 index 0000000..db4a3b9 --- /dev/null +++ b/tests/Examples/Invalid/RootDTOWithInvalidScalarAttribute.php @@ -0,0 +1,17 @@ + $children + * @param array $tagIds + */ + public function __construct( + #[Scalar(mappedPropertyName: 'parent_name')] + public string $name, + #[ReferenceArray(referenceClassName: NamedArgumentsChildDTO::class)] + public array $children, + #[ScalarArray(mappedPropertyName: 'tag_id')] + public array $tagIds, + ) {} +} diff --git a/tests/FlatMapperCreateMappingTest.php b/tests/FlatMapperCreateMappingTest.php index 955c2b0..3d6ba73 100644 --- a/tests/FlatMapperCreateMappingTest.php +++ b/tests/FlatMapperCreateMappingTest.php @@ -12,6 +12,10 @@ use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\NameTransformation\InvalidNameTransformationDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTO as InvalidRootDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithEmptyClassIdentifier; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithInvalidReferenceArrayAttribute; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithInvalidReferenceArrayClass; +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\RootDTOWithoutConstructor; use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithReadonlyClassModifier; @@ -23,6 +27,7 @@ #[CoversMethod(FlatMapper::class, 'createMapping')] #[CoversMethod(FlatMapper::class, 'createMappingRecursive')] +#[CoversMethod(FlatMapper::class, 'getAttributeArgument')] #[CoversMethod(FlatMapper::class, 'setCacheService')] #[CoversClass(MappingCreationException::class)] class FlatMapperCreateMappingTest extends TestCase @@ -134,4 +139,32 @@ public function testCreateMappingWithCircularReferenceArrayAsserts(): void $this->expectExceptionMessageMatches('/Circular ReferenceArray mapping detected/'); (new FlatMapper())->createMapping(CycleRootDTO::class); } + + public function testCreateMappingWithInvalidReferenceArrayClassAsserts(): void + { + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/This\\\\Class\\\\Does\\\\Not\\\\Exist is not a valid class name/'); + (new FlatMapper())->createMapping(RootDTOWithInvalidReferenceArrayClass::class); + } + + public function testCreateMappingWithInvalidReferenceArrayAttributeAsserts(): void + { + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Invalid ReferenceArray attribute/'); + (new FlatMapper())->createMapping(RootDTOWithInvalidReferenceArrayAttribute::class); + } + + public function testCreateMappingWithInvalidScalarArrayAttributeAsserts(): void + { + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Invalid ScalarArray attribute/'); + (new FlatMapper())->createMapping(RootDTOWithInvalidScalarArrayAttribute::class); + } + + public function testCreateMappingWithInvalidScalarAttributeAsserts(): void + { + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Invalid Scalar attribute/'); + (new FlatMapper())->createMapping(RootDTOWithInvalidScalarAttribute::class); + } } diff --git a/tests/FlatMapperTest.php b/tests/FlatMapperTest.php index a81a883..600217a 100644 --- a/tests/FlatMapperTest.php +++ b/tests/FlatMapperTest.php @@ -19,6 +19,8 @@ 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,6 +86,18 @@ public function testMappingDataWithMissingPropertyAsserts(): void ((new FlatMapper())->map(AuthorDTO::class, $results)); } + public function testMappingDataWithMissingScalarArrayPropertyAsserts(): void + { + $this->expectException(MappingException::class); + $this->expectExceptionMessageMatches('/Data does not contain required property: object2_id/'); + + $results = [ + ['object1_id' => 1, 'object1_name' => 'Root 1'], + ]; + + ((new FlatMapper())->map(ScalarArrayDTO::class, $results)); + } + public function testMappingDataWithBadConstructorTypeAsserts(): void { $this->expectException(MappingException::class); @@ -472,6 +486,30 @@ public function testMapWithNameTransformationBackwardCompatibility(): void ); } + public function testMapWithNamedAttributeArguments(): void + { + $results = [ + ['parent_id' => 1, 'parent_name' => 'Parent 1', 'child_id' => 11, 'child_name' => 'Child 11', 'tag_id' => 500], + ['parent_id' => 1, 'parent_name' => 'Parent 1', 'child_id' => 12, 'child_name' => 'Child 12', 'tag_id' => 501], + ['parent_id' => 2, 'parent_name' => 'Parent 2', 'child_id' => null, 'child_name' => null, 'tag_id' => null], + ]; + + $flatMapperResults = ((new FlatMapper())->map(NamedArgumentsParentDTO::class, $results)); + + $parent1 = new NamedArgumentsParentDTO( + 'Parent 1', + [11 => new NamedArgumentsChildDTO(11, 'Child 11'), 12 => new NamedArgumentsChildDTO(12, 'Child 12')], + [500, 501] + ); + $parent2 = new NamedArgumentsParentDTO('Parent 2', [], []); + $handmadeResult = [1 => $parent1, 2 => $parent2]; + + $this->assertSame( + var_export($flatMapperResults, true), + var_export($handmadeResult, true) + ); + } + /** * @return list> */ diff --git a/tests/Functional/BundleFunctionalTest.php b/tests/Functional/BundleFunctionalTest.php index f49c527..b587fe8 100644 --- a/tests/Functional/BundleFunctionalTest.php +++ b/tests/Functional/BundleFunctionalTest.php @@ -3,10 +3,13 @@ namespace Pixelshaped\FlatMapperBundle\Tests\Functional; +use FilesystemIterator; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Pixelshaped\FlatMapperBundle\FlatMapper; use Pixelshaped\FlatMapperBundle\PixelshapedFlatMapperBundle; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\HttpKernel\Kernel; use Symfony\Contracts\Cache\CacheInterface; @@ -16,6 +19,29 @@ #[CoversClass(PixelshapedFlatMapperBundle::class)] class BundleFunctionalTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + $cacheDir = dirname(__DIR__, 2).'/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); + } + public function testServiceWiring(): void { $kernel = new PixelshapedFlatMapperTestingKernel('test', true); From c1866d276c8d524035fd94b5b8561cb4933fb852 Mon Sep 17 00:00:00 2001 From: Renaud Date: Sun, 15 Feb 2026 23:48:36 +0100 Subject: [PATCH 6/7] More exotic tests --- src/FlatMapper.php | 7 +++++++ tests/Examples/Invalid/RootAbstractDTO.php | 17 +++++++++++++++ tests/FlatMapperCreateMappingTest.php | 8 ++++++++ tests/FlatMapperTest.php | 24 ++++++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 tests/Examples/Invalid/RootAbstractDTO.php diff --git a/src/FlatMapper.php b/src/FlatMapper.php index 64d92bd..bf858a7 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -90,6 +90,10 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde $reflectionClass = new ReflectionClass($dtoClassName); + if (!$reflectionClass->isInstantiable()) { + throw new MappingCreationException('Class "' . $dtoClassName . '" is not instantiable.'); + } + $constructor = $reflectionClass->getConstructor(); if($constructor === null) { @@ -261,6 +265,9 @@ public function map(string $dtoClassName, iterable $data): array { if (isset($this->objectsMapping[$dtoClassName][$foreignObjectClassOrIdentifier])) { // Handles ReferenceArray attribute $foreignIdentifier = $this->objectIdentifiers[$dtoClassName][$foreignObjectClassOrIdentifier]; + if (!array_key_exists($foreignIdentifier, $row)) { + throw new MappingException('Identifier not found: ' . $foreignIdentifier); + } if($row[$foreignIdentifier] !== null) { $referencesMap[$objectClass][$objectIdentifier][$objectProperty][$row[$foreignIdentifier]] = $objectsMap[$foreignObjectClassOrIdentifier][$row[$foreignIdentifier]]; } diff --git a/tests/Examples/Invalid/RootAbstractDTO.php b/tests/Examples/Invalid/RootAbstractDTO.php new file mode 100644 index 0000000..833293d --- /dev/null +++ b/tests/Examples/Invalid/RootAbstractDTO.php @@ -0,0 +1,17 @@ +createMapping(RootDTOWithoutConstructor::class); } + public function testCreateMappingWithAbstractClassAsserts(): void + { + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/is not instantiable/'); + (new FlatMapper())->createMapping(RootAbstractDTO::class); + } + public function testCreateMappingWithEmptyClassIdentifierAsserts(): void { $this->expectException(MappingCreationException::class); diff --git a/tests/FlatMapperTest.php b/tests/FlatMapperTest.php index 600217a..ac5d9ae 100644 --- a/tests/FlatMapperTest.php +++ b/tests/FlatMapperTest.php @@ -58,6 +58,30 @@ public function testMappingDataWithMissingForeignIdentifierPropertyAsserts(): vo ((new FlatMapper())->map(AuthorDTO::class, $results)); } + public function testMappingDataWithMissingForeignIdentifierPropertyAssertsWhenRootIdentifierIsCheckedFirst(): void + { + $this->expectException(MappingException::class); + $this->expectExceptionMessageMatches('/Identifier not found: book_id/'); + + $flatMapper = new FlatMapper(); + $flatMapper->createMapping(AuthorDTO::class); + + $objectIdentifiersProperty = (new \ReflectionClass($flatMapper))->getProperty('objectIdentifiers'); + /** @var array> $objectIdentifiers */ + $objectIdentifiers = $objectIdentifiersProperty->getValue($flatMapper); + $objectIdentifiers[AuthorDTO::class] = [ + AuthorDTO::class => 'author_id', + BookDTO::class => 'book_id', + ]; + $objectIdentifiersProperty->setValue($flatMapper, $objectIdentifiers); + + $results = [ + ['author_id' => 1, 'author_name' => 'Alice Brian', 'book_name' => 'Travelling as a group', 'book_publisher_name' => 'TravelBooks'], + ]; + + $flatMapper->map(AuthorDTO::class, $results); + } + public function testMappingDataWithBadlyNamedPropertyAsserts(): void { $this->expectException(MappingException::class); From c01c2552d1f090a774b70cd98dfc1d443520dd3f Mon Sep 17 00:00:00 2001 From: Renaud Date: Sun, 15 Feb 2026 23:57:09 +0100 Subject: [PATCH 7/7] Add a test for empty identifiers --- src/FlatMapper.php | 5 ++++- .../RootDTOWithEmptyStringClassIdentifier.php | 16 ++++++++++++++++ .../RootDTOWithEmptyStringPropertyIdentifier.php | 16 ++++++++++++++++ tests/FlatMapperCreateMappingTest.php | 16 ++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/Examples/Invalid/RootDTOWithEmptyStringClassIdentifier.php create mode 100644 tests/Examples/Invalid/RootDTOWithEmptyStringPropertyIdentifier.php diff --git a/src/FlatMapper.php b/src/FlatMapper.php index bf858a7..3430f2a 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -107,7 +107,7 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde switch ($attribute->getName()) { case Identifier::class: $mappedPropertyName = $this->getAttributeArgument($attribute, 'mappedPropertyName'); - if ($mappedPropertyName !== null) { + if ($mappedPropertyName !== null && $mappedPropertyName !== '') { $objectIdentifiers[$dtoClassName] = $mappedPropertyName; $identifiersCount++; } else { @@ -166,6 +166,9 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde $identifiersCount++; $isIdentifier = true; $mappedPropertyName = $this->getAttributeArgument($attribute, 'mappedPropertyName'); + if($mappedPropertyName === '') { + throw new MappingCreationException('Invalid Identifier attribute for '.$dtoClassName.'::$'.$propertyName); + } if($mappedPropertyName !== null) { $columnName = $mappedPropertyName; } diff --git a/tests/Examples/Invalid/RootDTOWithEmptyStringClassIdentifier.php b/tests/Examples/Invalid/RootDTOWithEmptyStringClassIdentifier.php new file mode 100644 index 0000000..5bf3037 --- /dev/null +++ b/tests/Examples/Invalid/RootDTOWithEmptyStringClassIdentifier.php @@ -0,0 +1,16 @@ +createMapping(RootDTOWithEmptyClassIdentifier::class); } + public function testCreateMappingWithEmptyStringClassIdentifierAsserts(): void + { + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches("/The Identifier attribute cannot be used without a property name when used as a Class attribute/"); + (new FlatMapper())->createMapping(RootDTOWithEmptyStringClassIdentifier::class); + } + + public function testCreateMappingWithEmptyStringPropertyIdentifierAsserts(): void + { + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches('/Invalid Identifier attribute/'); + (new FlatMapper())->createMapping(RootDTOWithEmptyStringPropertyIdentifier::class); + } + public function testCreateMappingWithInvalidNameTransformationAsserts(): void { $this->expectException(MappingCreationException::class);