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
56 changes: 56 additions & 0 deletions src/Hydrator/DoctrineObjectWithComputed.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, mixed>
*/
#[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
*
Expand Down
69 changes: 69 additions & 0 deletions test/Entity/TestEntityWithMagicCall.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity;

use BadMethodCallException;
use Doctrine\ORM\Mapping as ORM;

/**
* Test entity that implements __call to handle getXxx() method calls.
* Used to verify that DoctrineObjectWithComputed falls back to __call
* when no explicit getter or isser exists for a field.
*/
#[ORM\Entity]
class TestEntityWithMagicCall
{
#[ORM\Id]
#[ORM\Column(type: 'integer')]
#[ORM\GeneratedValue]
private int $id;

/** Field with an explicit getter — parent extractByValue handles it normally */
#[ORM\Column(type: 'string')]
private string $regularField;

/** Field with no explicit getter — __call must be used to extract it */
#[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<mixed> $args
*/
public function __call(string $name, array $args): mixed
{
if ($name === 'getMagicField') {
return $this->magicField;
}

throw new BadMethodCallException('Method ' . $name . ' not found');
}
}
83 changes: 83 additions & 0 deletions test/Entity/TestEntityWithMagicCallAndFilterProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity;

use BadMethodCallException;
use Doctrine\ORM\Mapping as ORM;
use Laminas\Hydrator\Filter\FilterInterface;
use Laminas\Hydrator\Filter\FilterProviderInterface;

/**
* Variant of TestEntityWithMagicCall that also implements FilterProviderInterface.
* Used to exercise the $object->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<mixed> $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;
}
};
}
}
108 changes: 108 additions & 0 deletions test/Unit/Hydrator/DoctrineObjectWithComputedTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']);
}
}
Loading