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
7 changes: 6 additions & 1 deletion src/Hydrator/DoctrineObjectWithComputed.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,8 @@ protected function extractByReference(object $object): array
* field values by calling registered extractors. A computed field with
* arguments has a value for each set of them, not one value, so it is
* left out. A batched computed field would be a query for each entity
* extracted, so it is left out too.
* extracted, so it is left out too, as is a mapped property of the
* same name which is not exposed.
*
* @return array<array-key, mixed>
*/
Expand All @@ -245,7 +246,11 @@ public function extract(object $object): array
$data = $this->extractFields($object);

foreach ($this->getComputedFieldNames() as $fieldName) {
// A mapped property of the same name which is not exposed is not
// the computed field's value
if ($this->hasComputedFieldArguments($fieldName) || isset($this->batchExtractors[$fieldName])) {
unset($data[$fieldName]);

continue;
}

Expand Down
58 changes: 27 additions & 31 deletions src/Resolve/FieldResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ public function __invoke(mixed $source, mixed $args, mixed $context, ResolveInfo

// A hit is decided by the entity, not the field, so a null field is
// served from the extract rather than extracting again
$values = $this->extractValues[$source] ?? null;
$values = $this->extractValues[$source] ?? null;
$hydrator = $this->getHydrator($source);

if ($values === null) {
/**
Expand All @@ -73,7 +74,6 @@ public function __invoke(mixed $source, mixed $args, mixed $context, ResolveInfo
}

// Computed fields are not extracted here; each is computed when queried
$hydrator = $this->getHydrator($source);
$values = $hydrator instanceof DoctrineObjectWithComputed
? $hydrator->extractFields($source)
: $hydrator->extract($source);
Expand All @@ -82,35 +82,31 @@ public function __invoke(mixed $source, mixed $args, mixed $context, ResolveInfo

$key = $info->fieldName;

if (! array_key_exists($key, $values)) {
$hydrator = $this->getHydrator($source);

if ($hydrator instanceof DoctrineObjectWithComputed && $hydrator->hasComputedField($info->fieldName)) {
// A computed field with arguments has a value for each set of
// them, as aliases of the field may give different arguments
$args = is_array($args) ? $args : [];
if ($hydrator->hasComputedFieldArguments($info->fieldName)) {
$key .= '(' . serialize($args) . ')';
}

if (! array_key_exists($key, $values)) {
// A batched field is loaded with the other entities waiting for it
$batchExtractor = $this->config->getBatchAssociations()
? $hydrator->getBatchExtractor($info->fieldName)
: null;

/** @psalm-suppress MixedAssignment */
$values[$key] = $batchExtractor !== null
? $this->computedFieldBatchLoader->defer(
$source,
$this->entityTypeContainer->getExposedClass($source),
$info->fieldName,
$batchExtractor,
$args,
)
: $hydrator->extractComputedField($source, $info->fieldName, $args);
$this->extractValues[$source] = $values;
}
// A computed field wins over a mapped property of the same name which
// is not exposed, so its value is kept under a key no property has
if ($hydrator instanceof DoctrineObjectWithComputed && $hydrator->hasComputedField($info->fieldName)) {
// A computed field with arguments has a value for each set of
// them, as aliases of the field may give different arguments
$args = is_array($args) ? $args : [];
$key .= '(' . ($hydrator->hasComputedFieldArguments($info->fieldName) ? serialize($args) : '') . ')';

if (! array_key_exists($key, $values)) {
// A batched field is loaded with the other entities waiting for it
$batchExtractor = $this->config->getBatchAssociations()
? $hydrator->getBatchExtractor($info->fieldName)
: null;

/** @psalm-suppress MixedAssignment */
$values[$key] = $batchExtractor !== null
? $this->computedFieldBatchLoader->defer(
$source,
$this->entityTypeContainer->getExposedClass($source),
$info->fieldName,
$batchExtractor,
$args,
)
: $hydrator->extractComputedField($source, $info->fieldName, $args);
$this->extractValues[$source] = $values;
}
}

Expand Down
56 changes: 56 additions & 0 deletions test/Entity/TestComputedShadowsProperty.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity;

use ApiSkeletons\Doctrine\ORM\GraphQL\Attribute as GraphQL;
use Doctrine\ORM\Mapping as ORM;

/**
* A computed field named as a mapped property which is not exposed, which
* has its own getter
*/
#[GraphQL\Entity(group: 'computedShadowTest')]
#[ORM\Entity]
class TestComputedShadowsProperty
{
#[GraphQL\Field(group: 'computedShadowTest')]
#[ORM\Id]
#[ORM\Column(type: 'integer')]
#[ORM\GeneratedValue]
private int $id;

#[ORM\Column(type: 'string')]
private string $test = 'property';

#[ORM\Column(type: 'string')]
private string $label = 'property';

public function getId(): int
{
return $this->id;
}

public function getTest(): string
{
return $this->test;
}

#[GraphQL\ComputedField(type: 'string', name: 'test', group: 'computedShadowTest')]
public function computeTest(): string
{
return 'computed';
}

public function getLabel(): string
{
return $this->label;
}

#[GraphQL\ComputedField(type: 'string', name: 'label', group: 'computedShadowTest')]
public function computeLabel(string $prefix): string
{
return $prefix . 'computed';
}
}
55 changes: 55 additions & 0 deletions test/Feature/ComputedField/ComputedFieldEdgeCasesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@
use ApiSkeletons\Doctrine\ORM\GraphQL\Config;
use ApiSkeletons\Doctrine\ORM\GraphQL\Driver;
use ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata as MetadataException;
use ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator\HydratorContainer;
use ApiSkeletons\Doctrine\ORM\GraphQL\Metadata\MetadataFactory;
use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\TestComputedShadowsProperty;
use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\TestEntityWithCollision;
use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\TestEntityWithGetMethod;
use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\TestEntityWithIsMethod;
use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\TestEntityWithPlainMethod;
use ApiSkeletonsTest\Doctrine\ORM\GraphQL\TestCase;
use GraphQL\GraphQL;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Schema;
use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionClass;
use ReflectionMethod;
Expand Down Expand Up @@ -92,4 +97,54 @@ public function testFieldNameIsDerivedFromTheMethodName(string $methodName, stri

$this->assertSame($fieldName, (new ReflectionMethod($factory, 'deriveFieldNameFromMethod'))->invoke($factory, $methodName));
}

/**
* A computed field named as a mapped property which is not exposed is
* resolved by its method, not by the property's getter
*
* @return array<string, array{bool}>
*/
public static function extractByValueProvider(): array
{
return [
'by value' => [true],
'by reference' => [false],
];
}

#[DataProvider('extractByValueProvider')]
public function testComputedFieldWinsOverAnUnexposedProperty(bool $extractByValue): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist(new TestComputedShadowsProperty());
$entityManager->flush();
$entityManager->clear();

$driver = new Driver($entityManager, new Config([
'group' => 'computedShadowTest',
'extractByValue' => $extractByValue,
]));

$schema = new Schema([
'query' => new ObjectType([
'name' => 'query',
'fields' => [
'entities' => $driver->completeConnection(TestComputedShadowsProperty::class),
],
]),
]);

$result = GraphQL::executeQuery($schema, '{ entities { edges { node { id test label(prefix: "a ") } } } }');
$output = $result->toArray();

$this->assertEmpty($output['errors'] ?? []);
$this->assertSame('computed', $output['data']['entities']['edges'][0]['node']['test']);
$this->assertSame('a computed', $output['data']['entities']['edges'][0]['node']['label']);

// extract() leaves out a computed field with arguments, and the property
$entity = $entityManager->getRepository(TestComputedShadowsProperty::class)->findAll()[0];
$extracted = $driver->get(HydratorContainer::class)->get(TestComputedShadowsProperty::class)->extract($entity);
$this->assertSame('computed', $extracted['test']);
$this->assertArrayNotHasKey('label', $extracted);
}
}
Loading