Skip to content
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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
19 changes: 19 additions & 0 deletions docs/attributes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,25 @@ data from Doctrine entities. The hydrator library is
More information here:
`By Value and By Reference <https://www.doctrine-project.org/projects/doctrine-laminas-hydrator/en/3.0/by-value-by-reference.html#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
=====
Expand Down
6 changes: 6 additions & 0 deletions src/Attribute/Entity.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}

Expand Down Expand Up @@ -54,4 +55,9 @@ public function getTypeName(): string|null
{
return $this->typeName;
}

public function getMagicCall(): bool
{
return $this->magicCall;
}
}
18 changes: 12 additions & 6 deletions src/Hydrator/DoctrineObjectWithComputed.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
*
Expand Down Expand Up @@ -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<string, mixed>
*/
Expand All @@ -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;
}

Expand Down
3 changes: 3 additions & 0 deletions src/Hydrator/HydratorContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions src/Hydrator/Strategy/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/Metadata/GlobalEnable.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public function getMetadata(array $entityClasses): Metadata
$this->metadata[$entityClass] = [
'entityClass' => $entityClass,
'byValue' => $byValue,
'magicCall' => false,
'limit' => 0,
'fields' => [],
'excludeFilters' => [],
Expand Down
4 changes: 2 additions & 2 deletions src/Metadata/MetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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 */
Expand Down
3 changes: 2 additions & 1 deletion test/Feature/Metadata/CachingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down
30 changes: 27 additions & 3 deletions test/Unit/Hydrator/DoctrineObjectWithComputedTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,38 @@ 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.
*/
public function testExtractByValueInvokesMagicCallForFieldsWithoutGetter(): void
{
$em = $this->getEntityManager();
$hydrator = new DoctrineObjectWithComputed($em, true);
$hydrator = new DoctrineObjectWithComputed($em, true, true);

$entity = (new TestEntityWithMagicCall())
->setRegularField('regular value')
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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')
Expand Down
Loading