diff --git a/src/Attribute/Field.php b/src/Attribute/Field.php index c16a67f..4434775 100644 --- a/src/Attribute/Field.php +++ b/src/Attribute/Field.php @@ -27,6 +27,7 @@ public function __construct( private readonly string|null $hydratorStrategy = null, private readonly array $excludeFilters = [], private readonly array $includeFilters = [], + private readonly string|null $extractorMethod = null, ) { } @@ -54,4 +55,9 @@ public function getType(): string|null { return $this->type; } + + public function getExtractorMethod(): string|null + { + return $this->extractorMethod; + } } diff --git a/src/Filter/FilterFactory.php b/src/Filter/FilterFactory.php index aec3ecf..dbcc46e 100644 --- a/src/Filter/FilterFactory.php +++ b/src/Filter/FilterFactory.php @@ -132,6 +132,11 @@ protected function addFields(Entity $targetEntity, array $allowedFilters): array continue; } + // Fields with extractorMethod cannot be filtered at the database level + if (! empty($entityMetadata['fields'][$fieldName]['extractorMethod'])) { + continue; + } + $type = $this->typeContainer ->get($entityMetadata['fields'][$fieldName]['type']); diff --git a/src/Hydrator/HydratorContainer.php b/src/Hydrator/HydratorContainer.php index 953dff9..3015ed8 100644 --- a/src/Hydrator/HydratorContainer.php +++ b/src/Hydrator/HydratorContainer.php @@ -83,6 +83,26 @@ public function get(string $id): mixed /** @psalm-suppress MixedArgument */ $object->setAllowedFields(array_map('strval', array_keys($metadata['fields']))); + // Register extractorMethod fields as computed overrides + /** @psalm-suppress MixedArrayAccess, MixedAssignment */ + foreach ($metadata['fields'] as $fieldName => $fieldMetadata) { + /** @psalm-suppress MixedArrayAccess */ + $extractorMethod = $fieldMetadata['extractorMethod'] ?? null; + if ($extractorMethod === null) { + continue; + } + + // Use alias as data key when present so FieldResolver can find it by GraphQL field name + /** @psalm-suppress MixedArrayAccess */ + $dataKey = $fieldMetadata['alias'] ?? $fieldName; + + /** @psalm-suppress MixedArgument, MixedMethodCall */ + $object->addComputedField( + $dataKey, + static fn (object $entity): mixed => $entity->$extractorMethod(), + ); + } + // Register computed fields if (isset($metadata['computedFields'])) { /** @psalm-suppress MixedArrayAccess, MixedAssignment */ diff --git a/src/Hydrator/Strategy/Collection.php b/src/Hydrator/Strategy/Collection.php index e3d76d8..93803f6 100644 --- a/src/Hydrator/Strategy/Collection.php +++ b/src/Hydrator/Strategy/Collection.php @@ -157,7 +157,8 @@ 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()); diff --git a/src/Metadata/MetadataFactory.php b/src/Metadata/MetadataFactory.php index 09542ce..c049db3 100644 --- a/src/Metadata/MetadataFactory.php +++ b/src/Metadata/MetadataFactory.php @@ -172,6 +172,7 @@ private function buildMetadataForFields( 'hydratorStrategy' => $instance->getHydratorStrategy() ?? $this->getDefaultStrategy($entityClassMetadata->getTypeOfField($fieldName)), 'excludeFilters' => Filters::toStringArray($instance->getExcludeFilters()), + 'extractorMethod' => $instance->getExtractorMethod(), ]; /** @psalm-suppress MixedArrayAssignment */ diff --git a/test/Entity/Artist.php b/test/Entity/Artist.php index 8017d40..70a5098 100644 --- a/test/Entity/Artist.php +++ b/test/Entity/Artist.php @@ -28,6 +28,7 @@ #[GraphQL\Entity(group: 'ExtractionMap', limit: 1)] #[GraphQL\Entity(group: 'ExtractionMapDuplicate', limit: 1)] #[GraphQL\Entity(group: 'computedFieldTest')] +#[GraphQL\Entity(group: 'extractorMethodTest')] #[ORM\Entity] class Artist @@ -45,6 +46,7 @@ class Artist #[GraphQL\Field(group: 'ExtractionMap', alias: 'title')] #[GraphQL\Field(group: 'ExtractionMapDuplicate', alias: 'duplicate')] #[GraphQL\Field(group: 'computedFieldTest')] + #[GraphQL\Field(group: 'extractorMethodTest', extractorMethod: 'getNameUppercased')] #[ORM\Column(type: 'string', nullable: false)] private string $name; @@ -57,6 +59,7 @@ class Artist #[GraphQL\Field(group: 'AttributeLimit')] #[GraphQL\Field(group: 'ExtractionMapDuplicate', alias: 'duplicate')] #[GraphQL\Field(group: 'computedFieldTest')] + #[GraphQL\Field(group: 'extractorMethodTest')] #[ORM\Id] #[ORM\Column(type: 'bigint')] @@ -156,6 +159,14 @@ public function getPerformances(): Collection return $this->performances; } + /** + * Returns name uppercased — used as extractorMethod target in extractorMethodTest group + */ + public function getNameUppercased(): string + { + return strtoupper($this->name); + } + /** * Get full name in uppercase (for testing computed fields) */ diff --git a/test/Feature/ExtractorMethod/ExtractorMethodTest.php b/test/Feature/ExtractorMethod/ExtractorMethodTest.php new file mode 100644 index 0000000..3728800 --- /dev/null +++ b/test/Feature/ExtractorMethod/ExtractorMethodTest.php @@ -0,0 +1,70 @@ +getEntityManager(), new Config(['group' => 'extractorMethodTest'])); + + $driver->type(Artist::class); + + $metadata = $driver->get(EntityTypeContainer::class) + ->get(Artist::class) + ->getMetadata(); + + $this->assertEquals('getNameUppercased', $metadata['fields']['name']['extractorMethod']); + } + + public function testExtractorMethodResolvesToMethodReturn(): void + { + $driver = new Driver($this->getEntityManager(), new Config(['group' => 'extractorMethodTest'])); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'query', + 'fields' => [ + 'artists' => $driver->completeConnection(Artist::class), + ], + ]), + ]); + + $result = GraphQL::executeQuery($schema, '{ artists { edges { node { id name } } } }')->toArray(); + + $this->assertEmpty($result['errors'] ?? []); + $this->assertNotEmpty($result['data']['artists']['edges']); + + foreach ($result['data']['artists']['edges'] as $edge) { + // name should be the uppercased value from getNameUppercased(), not the raw stored value + $this->assertEquals(strtoupper($edge['node']['name']), $edge['node']['name']); + } + } + + public function testExtractorMethodFieldNotInFilters(): void + { + $driver = new Driver($this->getEntityManager(), new Config(['group' => 'extractorMethodTest'])); + + $filterType = $driver->filter(Artist::class); + $fields = $filterType->getFields(); + + // name has extractorMethod so must not appear in filters + $this->assertArrayNotHasKey('name', $fields); + + // id has no extractorMethod so must still be filterable + $this->assertArrayHasKey('id', $fields); + } +} diff --git a/test/Feature/Metadata/CachingTest.php b/test/Feature/Metadata/CachingTest.php index 9535dc3..5383bdc 100644 --- a/test/Feature/Metadata/CachingTest.php +++ b/test/Feature/Metadata/CachingTest.php @@ -38,7 +38,7 @@ public function testStaticMetadata(): void 'entityClass' => 'ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\User', 'byValue' => true, 'limit' => 0, - 'description' => '', + 'description' => null, 'excludeFilters' => [], 'typeName' => 'ApiSkeletonsTest_Doctrine_ORM_GraphQL_Entity_User_StaticMetadata', 'fields' => [ @@ -48,6 +48,7 @@ public function testStaticMetadata(): void 'type' => 'string', 'hydratorStrategy' => 'ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator\Strategy\FieldDefault', 'excludeFilters' => [], + 'extractorMethod' => null, ], 'recordings' => [ 'alias' => null,