diff --git a/composer.json b/composer.json index 4ed7ffe..0bb46fd 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,7 @@ "vendor/bin/phpstan analyze src --level=8", "vendor/bin/phpunit" ], - "coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage-report" + "coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text" }, "config": { "allow-plugins": { diff --git a/docs/attributes.rst b/docs/attributes.rst index a41adc7..b28b5a6 100644 --- a/docs/attributes.rst +++ b/docs/attributes.rst @@ -69,6 +69,25 @@ data from Doctrine entities. The hydrator library is More information here: `By Value and By Reference `_ +* ``magicCall`` - Default is ``false``. When set to ``true`` and ``byValue`` + is also ``true``, the hydrator will fall back to PHP's ``__call`` magic + method to extract fields that have no explicit getter (``getField()``) or + isser (``isField()``). Enable this only for entities that intentionally + use ``__call`` to handle property access, such as those backed by a + dynamic data source or a legacy accessor pattern. + + .. code-block:: php + + #[GraphQL\Entity(magicCall: true)] + class DynamicEntity + { + // Fields without explicit getters are accessed via __call + public function __call(string $name, array $args): mixed + { + // custom accessor logic + } + } + Field ===== diff --git a/src/Attribute/Entity.php b/src/Attribute/Entity.php index 27c80b1..c83c7cb 100644 --- a/src/Attribute/Entity.php +++ b/src/Attribute/Entity.php @@ -27,6 +27,7 @@ public function __construct( private readonly string|null $typeName = null, private readonly array $excludeFilters = [], private readonly array $includeFilters = [], + private readonly bool $magicCall = false, ) { } @@ -54,4 +55,9 @@ public function getTypeName(): string|null { return $this->typeName; } + + public function getMagicCall(): bool + { + return $this->magicCall; + } } diff --git a/src/Hydrator/DoctrineObjectWithComputed.php b/src/Hydrator/DoctrineObjectWithComputed.php index 1ebc8bb..5e2b903 100644 --- a/src/Hydrator/DoctrineObjectWithComputed.php +++ b/src/Hydrator/DoctrineObjectWithComputed.php @@ -5,6 +5,7 @@ namespace ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator; use Doctrine\Laminas\Hydrator\DoctrineObject; +use Doctrine\ORM\EntityManager; use Laminas\Hydrator\Filter\FilterProviderInterface; use Override; @@ -30,6 +31,11 @@ final class DoctrineObjectWithComputed extends DoctrineObject */ private array $computedFields = []; + public function __construct(EntityManager $objectManager, bool $byValue = true, private bool $magicCall = false) + { + parent::__construct($objectManager, $byValue); + } + /** * Register a computed field for extraction * @@ -60,11 +66,11 @@ public function getComputedFieldNames(): array } /** - * Extract values from an object using by-value logic, with __call fallback. + * Extract values from an object using by-value logic, with optional __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. + * When magicCall is enabled and the entity implements __call, getters that have no + * explicit method are invoked through __call so magic accessor patterns are honoured + * during extraction. magicCall must be explicitly enabled via the Entity attribute. * * @return array */ @@ -73,8 +79,8 @@ 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')) { + // __call fallback is opt-in and only applies when the entity has __call + if (! $this->magicCall || ! method_exists($object, '__call')) { return $data; } diff --git a/src/Hydrator/HydratorContainer.php b/src/Hydrator/HydratorContainer.php index 74bf24e..e0edc15 100644 --- a/src/Hydrator/HydratorContainer.php +++ b/src/Hydrator/HydratorContainer.php @@ -56,11 +56,14 @@ public function get(string $id): mixed $metadata = $entity->getMetadata(); /** @psalm-suppress MixedArrayAccess, MixedAssignment */ $byValue = $metadata['byValue']; + /** @psalm-suppress MixedArrayAccess, MixedAssignment */ + $magicCall = $metadata['magicCall'] ?? false; /** @psalm-suppress DirectConstructorCall, MixedArgument */ $object->__construct( $entityManager, $byValue, + $magicCall, ); // Create field strategy and assign to hydrator diff --git a/src/Hydrator/Strategy/Collection.php b/src/Hydrator/Strategy/Collection.php index e3d76d8..497b602 100644 --- a/src/Hydrator/Strategy/Collection.php +++ b/src/Hydrator/Strategy/Collection.php @@ -157,12 +157,11 @@ protected function getCollectionFromObjectByValue(): DoctrineCollection */ protected function getCollectionFromObjectByReference(): DoctrineCollection { - $object = $this->getObject(); + $object = $this->getObject(); + /** @psalm-suppress UndefinedDocblockClass */ $refl = $this->getClassMetadata()->getReflectionClass(); $reflProperty = $refl->getProperty($this->getCollectionName()); - $reflProperty->setAccessible(true); - /** @psalm-suppress MixedReturnStatement */ return $reflProperty->getValue($object); } diff --git a/src/Metadata/GlobalEnable.php b/src/Metadata/GlobalEnable.php index 83eee69..97f9bcd 100644 --- a/src/Metadata/GlobalEnable.php +++ b/src/Metadata/GlobalEnable.php @@ -41,6 +41,7 @@ public function getMetadata(array $entityClasses): Metadata $this->metadata[$entityClass] = [ 'entityClass' => $entityClass, 'byValue' => $byValue, + 'magicCall' => false, 'limit' => 0, 'fields' => [], 'excludeFilters' => [], diff --git a/src/Metadata/MetadataFactory.php b/src/Metadata/MetadataFactory.php index 09542ce..e45048c 100644 --- a/src/Metadata/MetadataFactory.php +++ b/src/Metadata/MetadataFactory.php @@ -122,6 +122,7 @@ private function buildMetadataForEntity(ReflectionClass $reflectionClass): bool $this->metadata[$reflectionClass->getName()] = [ 'entityClass' => $reflectionClass->getName(), 'byValue' => $this->config->getGlobalByValue() ?? $instance->getByValue(), + 'magicCall' => $instance->getMagicCall(), 'limit' => $instance->getLimit(), 'fields' => [], 'excludeFilters' => Filters::toStringArray($instance->getExcludeFilters()), @@ -220,8 +221,7 @@ private function buildMetadataForAssociations( 'description' => $instance->getDescription(), 'excludeFilters' => Filters::toStringArray($instance->getExcludeFilters()), 'eventName' => $instance->getEventName(), - 'hydratorStrategy' => $instance->getHydratorStrategy() ?? - Strategy\AssociationDefault::class, + 'hydratorStrategy' => $instance->getHydratorStrategy() ?? Strategy\AssociationDefault::class, ]; /** @psalm-suppress MixedArrayAssignment */ diff --git a/test/Feature/Metadata/CachingTest.php b/test/Feature/Metadata/CachingTest.php index 9535dc3..e352776 100644 --- a/test/Feature/Metadata/CachingTest.php +++ b/test/Feature/Metadata/CachingTest.php @@ -37,8 +37,9 @@ public function testStaticMetadata(): void 'ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\User' => [ 'entityClass' => 'ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\User', 'byValue' => true, + 'magicCall' => false, 'limit' => 0, - 'description' => '', + 'description' => null, 'excludeFilters' => [], 'typeName' => 'ApiSkeletonsTest_Doctrine_ORM_GraphQL_Entity_User_StaticMetadata', 'fields' => [ diff --git a/test/Unit/Hydrator/DoctrineObjectWithComputedTest.php b/test/Unit/Hydrator/DoctrineObjectWithComputedTest.php index caa16bb..062483f 100644 --- a/test/Unit/Hydrator/DoctrineObjectWithComputedTest.php +++ b/test/Unit/Hydrator/DoctrineObjectWithComputedTest.php @@ -101,6 +101,30 @@ public function testExtractByValueEarlyReturnWhenNoMagicCall(): void $this->assertEquals('Grateful Dead', $result['name']); } + /** + * When magicCall is false (default), __call is never used even when the + * entity implements it — fields without an explicit getter are simply absent. + */ + public function testExtractByValueDoesNotUseMagicCallWhenDisabled(): void + { + $em = $this->getEntityManager(); + $hydrator = new DoctrineObjectWithComputed($em, true, false); + + $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); + + $this->assertArrayHasKey('regularField', $result); + $this->assertArrayNotHasKey('magicField', $result); + } + /** * When an entity implements __call and a field has no explicit getter, * extractByValue must invoke the getter via __call to populate the field. @@ -108,7 +132,7 @@ public function testExtractByValueEarlyReturnWhenNoMagicCall(): void public function testExtractByValueInvokesMagicCallForFieldsWithoutGetter(): void { $em = $this->getEntityManager(); - $hydrator = new DoctrineObjectWithComputed($em, true); + $hydrator = new DoctrineObjectWithComputed($em, true, true); $entity = (new TestEntityWithMagicCall()) ->setRegularField('regular value') @@ -137,7 +161,7 @@ public function testExtractByValueInvokesMagicCallForFieldsWithoutGetter(): void public function testExtractByValueFiltersOutMagicCallFieldWhenFilterRejects(): void { $em = $this->getEntityManager(); - $hydrator = new DoctrineObjectWithComputed($em, true); + $hydrator = new DoctrineObjectWithComputed($em, true, true); // Reject magicField; allow everything else $hydrator->addFilter( @@ -168,7 +192,7 @@ public function testExtractByValueFiltersOutMagicCallFieldWhenFilterRejects(): v public function testExtractByValueUsesEntityFilterWhenFilterProviderImplemented(): void { $em = $this->getEntityManager(); - $hydrator = new DoctrineObjectWithComputed($em, true); + $hydrator = new DoctrineObjectWithComputed($em, true, true); $entity = (new TestEntityWithMagicCallAndFilterProvider()) ->setRegularField('regular value')