Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: Add specialized exceptions, add code coverage #7

Merged
merged 3 commits into from
Jul 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,18 @@ jobs:
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
tools: phpunit, composer:v2
coverage: xdebug3
- uses: actions/checkout@v4

- name: Install Dependencies
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
- name: PHPStan
run: vendor/bin/phpstan
- name: PHPUnit
run: vendor/bin/phpunit
run: vendor/bin/phpunit --coverage-clover=coverage.xml

- name: Upload coverage reports to Codecov
uses: codecov/[email protected]
with:
token: ${{ secrets.CODECOV_TOKEN }}
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
/vendor/
/.phpunit.cache/
/var/
composer.lock
composer.lock
coverage.xml
15 changes: 15 additions & 0 deletions src/Exception/MappingCreationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);

namespace Pixelshaped\FlatMapperBundle\Exception;

class MappingCreationException extends \RuntimeException
{
public function __construct(
string $message = "",
int $code = 0,
\Throwable|null $previous = null
) {
parent::__construct('An error occurred during mapping creation: '.$message, $code, $previous);
}
}
15 changes: 15 additions & 0 deletions src/Exception/MappingException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);

namespace Pixelshaped\FlatMapperBundle\Exception;

class MappingException extends \RuntimeException
{
public function __construct(
string $message = "",
int $code = 0,
\Throwable|null $previous = null
) {
parent::__construct('An error occurred during mapping: '.$message, $code, $previous);
}
}
49 changes: 29 additions & 20 deletions src/FlatMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
use Pixelshaped\FlatMapperBundle\Attributes\Identifier;
use Pixelshaped\FlatMapperBundle\Attributes\InboundPropertyName;
use Pixelshaped\FlatMapperBundle\Attributes\ReferencesArray;
use Pixelshaped\FlatMapperBundle\Exception\MappingCreationException;
use Pixelshaped\FlatMapperBundle\Exception\MappingException;
use ReflectionClass;
use RuntimeException;
use Symfony\Contracts\Cache\CacheInterface;

class FlatMapper
Expand Down Expand Up @@ -36,11 +37,11 @@ public function setValidateMapping(bool $validateMapping): void
$this->validateMapping = $validateMapping;
}

/**
* @param class-string $dtoClassName
*/
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) {
Expand Down Expand Up @@ -75,7 +76,7 @@ private function createMappingRecursive(string $dtoClassName, array& $objectIden
$constructor = $reflectionClass->getConstructor();

if($constructor === null) {
throw new RuntimeException('Class "' . $dtoClassName . '" does not have a constructor.');
throw new MappingCreationException('Class "' . $dtoClassName . '" does not have a constructor.');
}

$identifiersCount = 0;
Expand All @@ -86,14 +87,14 @@ private function createMappingRecursive(string $dtoClassName, array& $objectIden
$objectIdentifiers[$dtoClassName] = $classIdentifierAttributes[0]->getArguments()[0];
$identifiersCount++;
} else {
throw new RuntimeException('The Identifier attribute cannot be used without a property name when used as a Class attribute');
throw new MappingCreationException('The Identifier attribute cannot be used without a property name when used as a Class attribute');
}
}

foreach ($constructor->getParameters() as $reflectionProperty) {
$propertyName = $reflectionProperty->getName();
foreach ($constructor->getParameters() as $reflectionParameter) {
$propertyName = $reflectionParameter->getName();
$isIdentifier = false;
foreach ($reflectionProperty->getAttributes() as $attribute) {
foreach ($reflectionParameter->getAttributes() as $attribute) {
if ($attribute->getName() === ReferencesArray::class) {
$objectsMapping[$dtoClassName][$propertyName] = (string)$attribute->getArguments()[0];
$this->createMappingRecursive($attribute->getArguments()[0], $objectIdentifiers, $objectsMapping);
Expand Down Expand Up @@ -121,11 +122,11 @@ private function createMappingRecursive(string $dtoClassName, array& $objectIden

if($this->validateMapping) {
if($identifiersCount !== 1) {
throw new RuntimeException($dtoClassName.' does not contain exactly one #[Identifier] attribute.');
throw new MappingCreationException($dtoClassName.' does not contain exactly one #[Identifier] attribute.');
}

if (count($objectIdentifiers) !== count(array_unique($objectIdentifiers))) {
throw new RuntimeException('Several data identifiers are identical: ' . print_r($objectIdentifiers, true));
throw new MappingCreationException('Several data identifiers are identical: ' . print_r($objectIdentifiers, true));
}
}

Expand All @@ -150,18 +151,15 @@ public function map(string $dtoClassName, iterable $data): array {
foreach ($data as $row) {
foreach ($this->objectIdentifiers[$dtoClassName] as $objectClass => $identifier) {
if (!array_key_exists($identifier, $row)) {
throw new RuntimeException('Identifier not found: ' . $identifier);
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])) {
$foreignIdentifier = $this->objectIdentifiers[$dtoClassName][$foreignObjectClassOrIdentifier];
if (!array_key_exists($foreignIdentifier, $row)) {
throw new RuntimeException('Foreign identifier not found: ' . $foreignIdentifier);
}
if($row[$foreignIdentifier] !== null) {
if($row[$foreignIdentifier] !== null) { // As objects are constructed from leafs, this array key has already been tested when the leaf was constructed itself
$referencesMap[$objectClass][$row[$identifier]][$objectProperty][$row[$foreignIdentifier]] = $objectsMap[$foreignObjectClassOrIdentifier][$row[$foreignIdentifier]];
}
$constructorValues[] = [];
Expand All @@ -171,13 +169,20 @@ public function map(string $dtoClassName, iterable $data): array {
}
$constructorValues[] = [];
} else {
throw new RuntimeException($foreignObjectClassOrIdentifier.' is neither a foreign identifier nor a foreign object class.');
throw new MappingException($foreignObjectClassOrIdentifier.' is neither a foreign identifier nor a foreign object class.');
}
} else {
if(!array_key_exists($objectProperty, $row)) {
throw new MappingException('Data does not contain required property: ' . $objectProperty);
}
$constructorValues[] = $row[$objectProperty];
}
}
$dtoInstance = new $objectClass(...$constructorValues);
try {
$dtoInstance = new $objectClass(...$constructorValues);
} catch (\TypeError $e) {
throw new MappingException('Cannot construct object: '.$e->getMessage());
}
$objectsMap[$objectClass][$row[$identifier]] = $dtoInstance;
}
}
Expand All @@ -201,8 +206,12 @@ private function linkObjects(array $referencesMap, array $objectsMap): void
foreach ($references as $identifier => $foreignObjects) {
foreach ($foreignObjects as $mappedProperty => $foreignObjectIdentifiers) {
if (isset($objectsMap[$objectClass][$identifier])) {
$reflectionClass = new ReflectionClass($objectClass);
$arrayProperty = $reflectionClass->getProperty($mappedProperty);
try {
$reflectionClass = new ReflectionClass($objectClass);
$arrayProperty = $reflectionClass->getProperty($mappedProperty);
} catch (\ReflectionException $e) {
throw new MappingException($e->getMessage(), $e->getCode(), $e);
}
$arrayProperty->setValue($objectsMap[$objectClass][$identifier], $foreignObjectIdentifiers);
}
}
Expand Down
84 changes: 75 additions & 9 deletions tests/FlatMapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

namespace Pixelshaped\FlatMapperBundle\Tests;

use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Pixelshaped\FlatMapperBundle\Exception\MappingCreationException;
use Pixelshaped\FlatMapperBundle\Exception\MappingException;
use Pixelshaped\FlatMapperBundle\FlatMapper;
use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTO as InvalidRootDTO;
use Pixelshaped\FlatMapperBundle\Tests\Examples\Invalid\RootDTOWithEmptyClassIdentifier;
Expand All @@ -18,10 +20,10 @@
use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ReferencesArray\AuthorDTO;
use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ReferencesArray\BookDTO;
use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\WithoutAttributeDTO;
use RuntimeException;

#[CoversMethod(FlatMapper::class, 'createMapping')]
#[CoversMethod(FlatMapper::class, 'map')]
#[CoversClass(FlatMapper::class)]
#[CoversClass(MappingException::class)]
#[CoversClass(MappingCreationException::class)]
class FlatMapperTest extends TestCase
{
public function testCreateMappingWithValidDTOsDoesNotAssert(): void
Expand All @@ -32,46 +34,110 @@ public function testCreateMappingWithValidDTOsDoesNotAssert(): void
$mapper->createMapping(AuthorDTO::class);
}

public function testCreateMappingWrongClassNameAsserts(): void
{
$this->expectException(MappingCreationException::class);
$this->expectExceptionMessageMatches("/An error occurred during mapping creation: ThisIsNotAValidClassString is not a valid class name/");
$mapper = new FlatMapper();
$mapper->createMapping('ThisIsNotAValidClassString');
}

public function testCreateMappingWithSeveralIdenticalIdentifiersAsserts(): void
{
$this->expectException(RuntimeException::class);
$this->expectException(MappingCreationException::class);
$this->expectExceptionMessageMatches("/Several data identifiers are identical/");
$mapper = new FlatMapper();
$mapper->createMapping(InvalidRootDTO::class);
}

public function testCreateMappingWithTooManyIdentifiersAsserts(): void
{
$this->expectException(RuntimeException::class);
$this->expectException(MappingCreationException::class);
$this->expectExceptionMessageMatches("/does not contain exactly one #\[Identifier\] attribute/");
$mapper = new FlatMapper();
$mapper->createMapping(RootDTOWithTooManyIdentifiers::class);
}

public function testCreateMappingWithNoIdentifierAsserts(): void
{
$this->expectException(RuntimeException::class);
$this->expectException(MappingCreationException::class);
$this->expectExceptionMessageMatches("/does not contain exactly one #\[Identifier\] attribute/");
$mapper = new FlatMapper();
$mapper->createMapping(RootDTOWithNoIdentifier::class);
}

public function testCreateMappingWithNoConstructorAsserts(): void
{
$this->expectException(RuntimeException::class);
$this->expectException(MappingCreationException::class);
$this->expectExceptionMessageMatches("/does not have a constructor/");
$mapper = new FlatMapper();
$mapper->createMapping(RootDTOWithoutConstructor::class);
}

public function testCreateMappingWithEmptyClassIdentifierAsserts(): void
{
$this->expectException(RuntimeException::class);
$this->expectException(MappingCreationException::class);
$this->expectExceptionMessageMatches("/The Identifier attribute cannot be used without a property name when used as a Class attribute/");
$mapper = new FlatMapper();
$mapper->createMapping(RootDTOWithEmptyClassIdentifier::class);
}

public function testMappingDataWithMissingIdentifierPropertyAsserts(): void
{
$this->expectException(MappingException::class);
$this->expectExceptionMessageMatches('/Identifier not found: author_id/');

$results = [
['author_name' => 'Alice Brian', 'book_id' => 1, 'book_name' => 'Travelling as a group', 'book_publisher_name' => 'TravelBooks'],
['author_name' => 'Alice Brian', 'book_id' => 2, 'book_name' => 'My journeys', 'book_publisher_name' => 'Lorem Press'],
['author_name' => 'Bob Schmo', 'book_id' => 1, 'book_name' => 'Travelling as a group', 'book_publisher_name' => 'TravelBooks'],
];

((new FlatMapper())->map(AuthorDTO::class, $results));
}

public function testMappingDataWithMissingForeignIdentifierPropertyAsserts(): void
{
$this->expectException(MappingException::class);
$this->expectExceptionMessageMatches('/Identifier not found: book_id/');

$results = [
['author_id' => 1, 'author_name' => 'Alice Brian', 'book_name' => 'Travelling as a group', 'book_publisher_name' => 'TravelBooks'],
['author_id' => 1, 'author_name' => 'Alice Brian', 'book_name' => 'My journeys', 'book_publisher_name' => 'Lorem Press'],
['author_id' => 2, 'author_name' => 'Bob Schmo', 'book_name' => 'Travelling as a group', 'book_publisher_name' => 'TravelBooks'],
];

((new 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);
$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'],
['author_id' => 1, 'author_name' => 'Alice Brian', 'book_id' => 2, 'book_name' => 'My journeys'],
['author_id' => 2, 'author_name' => 'Bob Schmo', 'book_id' => 1, 'book_name' => 'Travelling as a group'],
];

((new FlatMapper())->map(AuthorDTO::class, $results));
}

public function testMapValidNestedDTOs(): void
{
$results = [
Expand Down
1 change: 1 addition & 0 deletions tests/Functional/BundleFunctionalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\HttpKernel\Kernel;

#[CoversClass(FlatMapper::class)]
#[CoversClass(PixelshapedFlatMapperBundle::class)]
class BundleFunctionalTest extends TestCase
{
Expand Down