From de407f524e303cf187d09dc0a2c58b8208da7c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Malte=20Hu=CC=88bner?= Date: Fri, 12 Jun 2026 17:11:16 +0200 Subject: [PATCH] Throw 404 EntityNotFoundException when query entity cannot be resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, ValueAssigner::assignEntityValueFromRepository() called the configured accessor on the repository lookup result before checking it for null. An unresolvable required query parameter (e.g. an unknown citySlug) therefore crashed with "Call to a member function on null", surfacing as a 500 Internal Server Error in consuming applications. Without an accessor the null lookup result silently skipped the filter, returning the unfiltered entity list instead — equally wrong. Now both cases throw the new EntityNotFoundException, which implements Symfony's HttpExceptionInterface with status 404, so consuming applications render a proper Not Found response without any changes. Co-Authored-By: Claude Fable 5 --- src/Exception/EntityNotFoundException.php | 26 ++++ src/Factory/ValueAssigner/ValueAssigner.php | 5 +- .../ValueAssigner/ValueAssignerTest.php | 132 ++++++++++++++++++ tests/Fixtures/SimpleEntityContainer.php | 18 +++ .../SimpleEntityContainerRepository.php | 48 +++++++ tests/Fixtures/SimpleEntityQuery.php | 22 +++ 6 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 src/Exception/EntityNotFoundException.php create mode 100644 tests/Fixtures/SimpleEntityContainer.php create mode 100644 tests/Fixtures/SimpleEntityContainerRepository.php create mode 100644 tests/Fixtures/SimpleEntityQuery.php diff --git a/src/Exception/EntityNotFoundException.php b/src/Exception/EntityNotFoundException.php new file mode 100644 index 0000000..e7a0be7 --- /dev/null +++ b/src/Exception/EntityNotFoundException.php @@ -0,0 +1,26 @@ +$methodName($queryParameterValue); - if ($queryField->getAccessor()) { + if (null !== $entity && $queryField->getAccessor()) { $accessMethodName = $queryField->getAccessor(); $entity = $entity->$accessMethodName(); } if (null === $entity) { - return $query; + throw new EntityNotFoundException($parameterName, (string) $queryParameterValue); } $setMethodName = $queryField->getMethodName(); diff --git a/tests/Factory/ValueAssigner/ValueAssignerTest.php b/tests/Factory/ValueAssigner/ValueAssignerTest.php index 54e335b..03aeea6 100644 --- a/tests/Factory/ValueAssigner/ValueAssignerTest.php +++ b/tests/Factory/ValueAssigner/ValueAssignerTest.php @@ -3,6 +3,8 @@ namespace MalteHuebner\DataQueryBundle\Tests\Factory\ValueAssigner; use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectRepository; +use MalteHuebner\DataQueryBundle\Exception\EntityNotFoundException; use MalteHuebner\DataQueryBundle\Factory\ValueAssigner\ValueAssigner; use MalteHuebner\DataQueryBundle\Factory\ValueAssigner\ValueAssignerInterface; use MalteHuebner\DataQueryBundle\Factory\ValueAssigner\ValueType; @@ -11,6 +13,10 @@ use MalteHuebner\DataQueryBundle\Query\BoundingBoxQuery; use MalteHuebner\DataQueryBundle\Parameter\SizeParameter; use MalteHuebner\DataQueryBundle\RequestParameterList\RequestParameterList; +use MalteHuebner\DataQueryBundle\Tests\Fixtures\SimpleEntity; +use MalteHuebner\DataQueryBundle\Tests\Fixtures\SimpleEntityContainer; +use MalteHuebner\DataQueryBundle\Tests\Fixtures\SimpleEntityContainerRepository; +use MalteHuebner\DataQueryBundle\Tests\Fixtures\SimpleEntityQuery; use PHPUnit\Framework\TestCase; class ValueAssignerTest extends TestCase @@ -309,4 +315,130 @@ public function testConvertToIntTrimsWhitespaceForParameter(): void $this->assertTrue(true); } + + public function testAssignQueryEntityValueAssignsEntityFromRepository(): void + { + $entity = new SimpleEntity(); + + $repository = $this->createMock(ObjectRepository::class); + $repository->method('find')->with('42')->willReturn($entity); + + $this->managerRegistry->method('getRepository')->with(SimpleEntity::class)->willReturn($repository); + + $list = new RequestParameterList(); + $list->add('simpleEntityId', '42'); + + $query = new SimpleEntityQuery(); + + $queryField = new QueryField(); + $queryField + ->setMethodName('setSimpleEntity') + ->setParameterName('simpleEntityId') + ->setType(SimpleEntity::class); + + $this->valueAssigner->assignQueryPropertyValueFromRequest($list, $query, $queryField); + + $this->assertSame($entity, $query->getSimpleEntity()); + } + + public function testAssignQueryEntityValueThrowsWhenEntityNotFound(): void + { + $repository = $this->createMock(ObjectRepository::class); + $repository->method('find')->willReturn(null); + + $this->managerRegistry->method('getRepository')->willReturn($repository); + + $list = new RequestParameterList(); + $list->add('simpleEntityId', '42'); + + $query = new SimpleEntityQuery(); + + $queryField = new QueryField(); + $queryField + ->setMethodName('setSimpleEntity') + ->setParameterName('simpleEntityId') + ->setType(SimpleEntity::class); + + $this->expectException(EntityNotFoundException::class); + $this->expectExceptionMessage('Could not find entity for query parameter "simpleEntityId" with value "42"'); + + $this->valueAssigner->assignQueryPropertyValueFromRequest($list, $query, $queryField); + } + + public function testAssignQueryEntityValueWithAccessorAssignsAccessedEntity(): void + { + $entity = new SimpleEntity(); + $repository = new SimpleEntityContainerRepository(new SimpleEntityContainer($entity)); + + $this->managerRegistry->method('getRepository')->with(SimpleEntityContainer::class)->willReturn($repository); + + $list = new RequestParameterList(); + $list->add('simpleEntitySlug', 'some-slug'); + + $query = new SimpleEntityQuery(); + + $queryField = new QueryField(); + $queryField + ->setMethodName('setSimpleEntity') + ->setParameterName('simpleEntitySlug') + ->setType(SimpleEntity::class) + ->setRepository(SimpleEntityContainer::class) + ->setRepositoryMethod('findOneBySlug') + ->setAccessor('getSimpleEntity'); + + $this->valueAssigner->assignQueryPropertyValueFromRequest($list, $query, $queryField); + + $this->assertSame($entity, $query->getSimpleEntity()); + } + + public function testAssignQueryEntityValueWithAccessorThrowsWhenLookupReturnsNull(): void + { + $repository = new SimpleEntityContainerRepository(null); + + $this->managerRegistry->method('getRepository')->willReturn($repository); + + $list = new RequestParameterList(); + $list->add('simpleEntitySlug', 'unknown-slug'); + + $query = new SimpleEntityQuery(); + + $queryField = new QueryField(); + $queryField + ->setMethodName('setSimpleEntity') + ->setParameterName('simpleEntitySlug') + ->setType(SimpleEntity::class) + ->setRepository(SimpleEntityContainer::class) + ->setRepositoryMethod('findOneBySlug') + ->setAccessor('getSimpleEntity'); + + $this->expectException(EntityNotFoundException::class); + $this->expectExceptionMessage('Could not find entity for query parameter "simpleEntitySlug" with value "unknown-slug"'); + + $this->valueAssigner->assignQueryPropertyValueFromRequest($list, $query, $queryField); + } + + public function testAssignQueryEntityValueThrowsWhenAccessorReturnsNull(): void + { + $repository = new SimpleEntityContainerRepository(new SimpleEntityContainer(null)); + + $this->managerRegistry->method('getRepository')->willReturn($repository); + + $list = new RequestParameterList(); + $list->add('simpleEntitySlug', 'some-slug'); + + $query = new SimpleEntityQuery(); + + $queryField = new QueryField(); + $queryField + ->setMethodName('setSimpleEntity') + ->setParameterName('simpleEntitySlug') + ->setType(SimpleEntity::class) + ->setRepository(SimpleEntityContainer::class) + ->setRepositoryMethod('findOneBySlug') + ->setAccessor('getSimpleEntity'); + + $this->expectException(EntityNotFoundException::class); + + $this->valueAssigner->assignQueryPropertyValueFromRequest($list, $query, $queryField); + } } diff --git a/tests/Fixtures/SimpleEntityContainer.php b/tests/Fixtures/SimpleEntityContainer.php new file mode 100644 index 0000000..a8bcb55 --- /dev/null +++ b/tests/Fixtures/SimpleEntityContainer.php @@ -0,0 +1,18 @@ +simpleEntity; + } +} diff --git a/tests/Fixtures/SimpleEntityContainerRepository.php b/tests/Fixtures/SimpleEntityContainerRepository.php new file mode 100644 index 0000000..3edfdd2 --- /dev/null +++ b/tests/Fixtures/SimpleEntityContainerRepository.php @@ -0,0 +1,48 @@ + + */ +class SimpleEntityContainerRepository implements ObjectRepository +{ + public function __construct( + private readonly ?SimpleEntityContainer $simpleEntityContainer = null + ) + { + + } + + public function findOneBySlug(string $slug): ?SimpleEntityContainer + { + return $this->simpleEntityContainer; + } + + public function find(mixed $id): ?object + { + return null; + } + + public function findAll(): array + { + return []; + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return []; + } + + public function findOneBy(array $criteria): ?object + { + return null; + } + + public function getClassName(): string + { + return SimpleEntityContainer::class; + } +} diff --git a/tests/Fixtures/SimpleEntityQuery.php b/tests/Fixtures/SimpleEntityQuery.php new file mode 100644 index 0000000..e911284 --- /dev/null +++ b/tests/Fixtures/SimpleEntityQuery.php @@ -0,0 +1,22 @@ +simpleEntity = $simpleEntity; + + return $this; + } + + public function getSimpleEntity(): ?SimpleEntity + { + return $this->simpleEntity; + } +}