diff --git a/src/Hydrator/DoctrineObjectWithComputed.php b/src/Hydrator/DoctrineObjectWithComputed.php index f3ea03e..1ebc8bb 100644 --- a/src/Hydrator/DoctrineObjectWithComputed.php +++ b/src/Hydrator/DoctrineObjectWithComputed.php @@ -5,9 +5,14 @@ namespace ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator; use Doctrine\Laminas\Hydrator\DoctrineObject; +use Laminas\Hydrator\Filter\FilterProviderInterface; use Override; +use function array_key_exists; use function array_keys; +use function get_class_methods; +use function in_array; +use function method_exists; /** * Extends DoctrineObject hydrator to support computed fields @@ -54,6 +59,57 @@ public function getComputedFieldNames(): array return array_keys($this->computedFields); } + /** + * Extract values from an object using by-value logic, with __call fallback. + * + * When neither getField() nor isField() exists as an explicit method, but the + * entity implements __call, the getter is invoked through __call so magic + * accessor patterns are honoured during extraction. + * + * @return array + */ + #[Override] + protected function extractByValue(object $object): array + { + $data = parent::extractByValue($object); + + // Nothing extra to do if the entity doesn't use __call + if (! method_exists($object, '__call')) { + return $data; + } + + $methods = get_class_methods($object); + $filter = $object instanceof FilterProviderInterface + ? $object->getFilter() + : $this->filterComposite; + + foreach ($this->getFieldNames() as $fieldName) { + if ($filter && ! $filter->filter($fieldName)) { + continue; + } + + $getter = 'get' . $this->inflector->classify($fieldName); + $isser = 'is' . $this->inflector->classify($fieldName); + $dataFieldName = $this->computeExtractFieldName($fieldName); + + // Skip fields already handled by the parent (explicit getter/isser found, + // or value already present in the extracted data) + if ( + array_key_exists($dataFieldName, $data) + || in_array($getter, $methods) + || in_array($isser, $methods) + ) { + continue; + } + + // Invoke getter via __call + /** @psalm-suppress MixedMethodCall, MixedAssignment */ + $data[$dataFieldName] = $this->extractValue($fieldName, $object->$getter(), $object); + } + + return $data; + } + /** * Extract values from object, including computed fields * diff --git a/test/Entity/TestEntityWithMagicCall.php b/test/Entity/TestEntityWithMagicCall.php new file mode 100644 index 0000000..0ca5fe9 --- /dev/null +++ b/test/Entity/TestEntityWithMagicCall.php @@ -0,0 +1,69 @@ +id; + } + + public function setRegularField(string $value): self + { + $this->regularField = $value; + + return $this; + } + + public function getRegularField(): string + { + return $this->regularField; + } + + public function setMagicField(string $value): self + { + $this->magicField = $value; + + return $this; + } + + /** + * Magic method — handles getMagicField() calls so extraction works + * without an explicit getter on the class. + * + * @param array $args + */ + public function __call(string $name, array $args): mixed + { + if ($name === 'getMagicField') { + return $this->magicField; + } + + throw new BadMethodCallException('Method ' . $name . ' not found'); + } +} diff --git a/test/Entity/TestEntityWithMagicCallAndFilterProvider.php b/test/Entity/TestEntityWithMagicCallAndFilterProvider.php new file mode 100644 index 0000000..c8415ba --- /dev/null +++ b/test/Entity/TestEntityWithMagicCallAndFilterProvider.php @@ -0,0 +1,83 @@ +getFilter() branch in DoctrineObjectWithComputed::extractByValue(). + */ +#[ORM\Entity] +class TestEntityWithMagicCallAndFilterProvider implements FilterProviderInterface +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue] + private int $id; + + #[ORM\Column(type: 'string')] + private string $regularField; + + #[ORM\Column(type: 'string')] + private string $magicField; + + public function getId(): int + { + return $this->id; + } + + public function setRegularField(string $value): self + { + $this->regularField = $value; + + return $this; + } + + public function getRegularField(): string + { + return $this->regularField; + } + + public function setMagicField(string $value): self + { + $this->magicField = $value; + + return $this; + } + + /** + * Magic method — handles getMagicField() calls so extraction works + * without an explicit getter on the class. + * + * @param array $args + */ + public function __call(string $name, array $args): mixed + { + if ($name === 'getMagicField') { + return $this->magicField; + } + + throw new BadMethodCallException('Method ' . $name . ' not found'); + } + + /** + * Implements FilterProviderInterface so that extractByValue takes the + * $object->getFilter() branch rather than $this->filterComposite. + * This filter allows all properties through. + */ + public function getFilter(): FilterInterface + { + return new class implements FilterInterface { + public function filter(string $property, object|null $instance = null): bool + { + return true; + } + }; + } +} diff --git a/test/Unit/Hydrator/DoctrineObjectWithComputedTest.php b/test/Unit/Hydrator/DoctrineObjectWithComputedTest.php index 68b679f..caa16bb 100644 --- a/test/Unit/Hydrator/DoctrineObjectWithComputedTest.php +++ b/test/Unit/Hydrator/DoctrineObjectWithComputedTest.php @@ -6,6 +6,8 @@ use ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator\DoctrineObjectWithComputed; use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\Artist; +use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\TestEntityWithMagicCall; +use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\TestEntityWithMagicCallAndFilterProvider; use ApiSkeletonsTest\Doctrine\ORM\GraphQL\TestCase; use function strlen; @@ -79,4 +81,110 @@ public function testAddComputedFieldAllowsMultipleFields(): void $this->assertTrue($this->hydrator->hasComputedField('field2')); $this->assertCount(2, $this->hydrator->getComputedFieldNames()); } + + /** + * When an entity has no __call method, extractByValue returns early after + * the parent extraction — the __call fallback loop is never entered. + */ + public function testExtractByValueEarlyReturnWhenNoMagicCall(): void + { + $hydrator = new DoctrineObjectWithComputed($this->getEntityManager(), true); + + $artist = $this->getEntityManager() + ->getRepository(Artist::class) + ->findOneBy(['name' => 'Grateful Dead']); + + $result = $hydrator->extract($artist); + + $this->assertArrayHasKey('name', $result); + $this->assertArrayHasKey('id', $result); + $this->assertEquals('Grateful Dead', $result['name']); + } + + /** + * When an entity implements __call and a field has no explicit getter, + * extractByValue must invoke the getter via __call to populate the field. + */ + public function testExtractByValueInvokesMagicCallForFieldsWithoutGetter(): void + { + $em = $this->getEntityManager(); + $hydrator = new DoctrineObjectWithComputed($em, true); + + $entity = (new TestEntityWithMagicCall()) + ->setRegularField('regular value') + ->setMagicField('magic value'); + $em->persist($entity); + $em->flush(); + $em->clear(); + + $persisted = $em->getRepository(TestEntityWithMagicCall::class)->findAll()[0]; + + $result = $hydrator->extract($persisted); + + // regularField has an explicit getter — parent extracts it + $this->assertArrayHasKey('regularField', $result); + $this->assertEquals('regular value', $result['regularField']); + + // magicField has no explicit getter — extracted via __call + $this->assertArrayHasKey('magicField', $result); + $this->assertEquals('magic value', $result['magicField']); + } + + /** + * When a Laminas filter is attached to the hydrator, fields rejected by + * the filter must be skipped even when __call would otherwise handle them. + */ + public function testExtractByValueFiltersOutMagicCallFieldWhenFilterRejects(): void + { + $em = $this->getEntityManager(); + $hydrator = new DoctrineObjectWithComputed($em, true); + + // Reject magicField; allow everything else + $hydrator->addFilter( + 'blockMagicField', + static fn (string $property): bool => $property !== 'magicField', + ); + + $entity = (new TestEntityWithMagicCall()) + ->setRegularField('regular value') + ->setMagicField('should be filtered'); + $em->persist($entity); + $em->flush(); + $em->clear(); + + $persisted = $em->getRepository(TestEntityWithMagicCall::class)->findAll()[0]; + + $result = $hydrator->extract($persisted); + + $this->assertArrayHasKey('regularField', $result); + $this->assertArrayNotHasKey('magicField', $result); + } + + /** + * When an entity implements FilterProviderInterface, extractByValue must + * call $object->getFilter() (line 83) rather than reading $this->filterComposite. + * The entity's own filter allows all fields, so magicField is still extracted via __call. + */ + public function testExtractByValueUsesEntityFilterWhenFilterProviderImplemented(): void + { + $em = $this->getEntityManager(); + $hydrator = new DoctrineObjectWithComputed($em, true); + + $entity = (new TestEntityWithMagicCallAndFilterProvider()) + ->setRegularField('regular value') + ->setMagicField('magic value'); + $em->persist($entity); + $em->flush(); + $em->clear(); + + $persisted = $em->getRepository(TestEntityWithMagicCallAndFilterProvider::class)->findAll()[0]; + + $result = $hydrator->extract($persisted); + + $this->assertArrayHasKey('regularField', $result); + $this->assertEquals('regular value', $result['regularField']); + + $this->assertArrayHasKey('magicField', $result); + $this->assertEquals('magic value', $result['magicField']); + } }