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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Key containers:
### Metadata System

Metadata is extracted from entity attributes and stored in a `Metadata` object (ArrayObject wrapper):
- `MetadataFactory` builds metadata from PHP attributes on entities. A `Field`, `Association` or `ComputedField` attribute of the configured group in a place it does not apply (a Field on an association, an embeddable or an unmapped property, an Association on a non-association, a ComputedField on a non-public or static method) throws `Exception\Metadata`
- `MetadataFactory` builds metadata from PHP attributes on entities. A `Field`, `Association` or `ComputedField` attribute of the configured group in a place it does not apply (a Field on an association, an embeddable or an unmapped property, an Association on a non-association, a ComputedField on a non-public or static method) throws `Exception\Metadata`. After the `metadata.build` event, an exposed association to, or a computed field of the type of, an entity not exposed in the group throws too
- The `Metadata` ArrayObject holds the array form, keyed by entity class; it is what the `metadata.build` event modifies and what is cached. `Metadata::toArray()` exports it with `'__version' => Metadata::FORMAT_VERSION` (a format version, bumped only when the array's shape changes) and the Driver requires that version when given cached metadata
- Code reads typed value objects built from the array: `Metadata\EntityMetadata` with `FieldMetadata`, `AssociationMetadata` and `ComputedFieldMetadata`, via `Entity::getEntityMetadata()`. Their `fromArray()` validates every key and `toArray()` reproduces the array exactly. `Entity::getMetadata()` still returns the array

Expand Down
3 changes: 2 additions & 1 deletion docs/attributes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,8 @@ etc. Associations which are to one types will just include the entity they are
associated with. Associations of the to many variety will become connections.
An ``Association`` attribute of the driver's group on a property which is not
an association throws ``ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata``
when the metadata is built.
when the metadata is built. So does an association to an entity which is
not exposed in the group, as its type is the association's type.

* ``alias`` - An alias to use as the GraphQL field name. Each field of a type
must have a unique name, so an alias may not be the name or alias of another
Expand Down
6 changes: 6 additions & 0 deletions docs/events.rst
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,12 @@ The ``BuildMetadata`` event has one function:
needed and the value is set by reference, just like the
QueryBuilder event above.

After the listeners, every entity an exposed association refers to, or a
computed field of an entity type returns, must be in the metadata. A
listener may therefore add the entity, and one which removes an entity
another refers to causes
``ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata``.


.. role:: raw-html(raw)
:format: html
Expand Down
11 changes: 11 additions & 0 deletions docs/upgrade.rst
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ were silently ignored. Each now throws
is built. Only the attributes of the driver's group are checked. Move the
attribute to where it applies, or remove it.

An association to an entity which is not exposed is an error
------------------------------------------------------------

An ``#[Association]`` to an entity with no ``#[Entity]`` attribute of the
driver's group failed every query of its entity's type, even one which did
not ask for the association, with an error naming only the entity it refers
to. It now throws ``ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata``
naming the association when the metadata is built, for every exposed entity,
including one which no schema uses. Expose the entity it refers to in the
group, or remove the attribute.

input() checks its field lists when it is called
------------------------------------------------

Expand Down
51 changes: 38 additions & 13 deletions src/Metadata/MetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use ReflectionProperty;

use function array_key_exists;
use function array_keys;
use function assert;
use function ctype_upper;
use function in_array;
Expand Down Expand Up @@ -84,13 +85,14 @@ public function getMetadata(): Metadata
$this->buildMetadataForComputedFields($reflectionClass);
}

$this->assertComputedEntityTypesAreExposed($entityClasses);

// Fire the metadata.build event
$this->eventDispatcher->dispatch(
new MetadataEvent($this->metadata, 'metadata.build'),
);

// After the event, as a listener may add or remove entities
$this->assertReferencedEntitiesAreExposed($entityClasses);

return $this->metadata;
}

Expand Down Expand Up @@ -342,33 +344,56 @@ private function buildMetadataForComputedFields(ReflectionClass $reflectionClass
}

/**
* A computed field whose type is the class of an entity must return an
* entity exposed in the group, whose type it is. Otherwise the error
* would come only when the schema is built, and would not name the field.
* The entities an exposed association refers to, and a computed field of
* an entity type returns, must be exposed in the group, as their types are
* the fields' types. Otherwise every query of the entity's type would
* fail, with an error naming neither the field nor its entity.
*
* @param list<string> $entityClasses Every entity class of the entity manager
*
* @throws MetadataException
*/
private function assertComputedEntityTypesAreExposed(array $entityClasses): void
private function assertReferencedEntitiesAreExposed(array $entityClasses): void
{
/** @psalm-suppress MixedAssignment The metadata built above */
$suffix = ', an entity which is not exposed in group ' . $this->config->getGroup()
. '. Add an Entity attribute of the group to it.';

/** @psalm-suppress MixedAssignment The metadata built above, as a listener may have changed it */
foreach ($this->metadata as $entityClass => $entityMetadata) {
$computedFields = is_array($entityMetadata) ? $entityMetadata['computedFields'] ?? [] : [];
assert(is_array($computedFields));
if (! is_array($entityMetadata) || ! in_array($entityClass, $entityClasses, true)) {
continue;
}

$classMetadata = $this->entityManager->getClassMetadata($entityClass);
$fields = $entityMetadata['fields'] ?? [];
$computed = $entityMetadata['computedFields'] ?? [];
assert(is_array($fields) && is_array($computed));

foreach (array_keys($fields) as $fieldName) {
if (! $classMetadata->hasAssociation((string) $fieldName)) {
continue;
}

$target = $classMetadata->getAssociationTargetClass((string) $fieldName);
if (isset($this->metadata[$target])) {
continue;
}

throw new MetadataException(
'Association ' . $fieldName . ' of entity ' . $entityClass . ' refers to ' . $target . $suffix,
);
}

/** @psalm-suppress MixedAssignment The metadata built above */
foreach ($computedFields as $fieldName => $computedField) {
foreach ($computed as $fieldName => $computedField) {
$type = is_array($computedField) ? $computedField['type'] ?? null : null;

if (! in_array($type, $entityClasses, true) || isset($this->metadata[$type])) {
continue;
}

throw new MetadataException(
'Computed field ' . $fieldName . ' of entity ' . $entityClass . ' is of type ' . $type
. ', an entity which is not exposed in group ' . $this->config->getGroup()
. '. Add an Entity attribute of the group to it.',
'Computed field ' . $fieldName . ' of entity ' . $entityClass . ' is of type ' . $type . $suffix,
);
}
}
Expand Down
1 change: 1 addition & 0 deletions test/Entity/Performance.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
#[GraphQL\Entity(group: 'LimitTest')]
#[GraphQL\Entity(group: 'AttributeLimit')]
#[GraphQL\Entity(group: 'ExtractionMap')]
#[GraphQL\Entity(group: 'entityTest')]

#[ORM\Entity]
class Performance
Expand Down
1 change: 1 addition & 0 deletions test/Entity/Recording.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#[GraphQL\Entity(typeName: 'entitytestrecording', description: 'Entity Test Recordings', group: 'entityTest')]
#[GraphQL\Entity(group: 'IncludeFiltersTest')]
#[GraphQL\Entity(group: 'CustomFieldStrategyTest')]
#[GraphQL\Entity(group: 'StaticMetadata')]
#[ORM\Entity]
class Recording
{
Expand Down
33 changes: 33 additions & 0 deletions test/Entity/UnexposedTargetItem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* An entity exposed in no group, which UnexposedTargetOwner's associations
* refer to
*/
#[ORM\Entity]
class UnexposedTargetItem
{
#[ORM\Id]
#[ORM\Column(type: 'integer')]
#[ORM\GeneratedValue]
private int $id;

#[ORM\ManyToOne(targetEntity: UnexposedTargetOwner::class, inversedBy: 'items')]
private UnexposedTargetOwner|null $owner = null;

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

public function getOwner(): UnexposedTargetOwner|null
{
return $this->owner;
}
}
57 changes: 57 additions & 0 deletions test/Entity/UnexposedTargetOwner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity;

use ApiSkeletons\Doctrine\ORM\GraphQL\Attribute as GraphQL;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
* Associations to an entity which is not exposed in the group: a collection
* in the UnexposedCollection group and a to-one in the UnexposedToOne group
*/
#[GraphQL\Entity(group: 'UnexposedCollection')]
#[GraphQL\Entity(group: 'UnexposedToOne')]
#[ORM\Entity]
class UnexposedTargetOwner
{
#[GraphQL\Field(group: 'UnexposedCollection')]
#[GraphQL\Field(group: 'UnexposedToOne')]
#[ORM\Id]
#[ORM\Column(type: 'integer')]
#[ORM\GeneratedValue]
private int $id;

/** @var Collection<int, UnexposedTargetItem> */
#[GraphQL\Association(group: 'UnexposedCollection')]
#[ORM\OneToMany(targetEntity: UnexposedTargetItem::class, mappedBy: 'owner')]
private Collection $items;

#[GraphQL\Association(group: 'UnexposedToOne')]
#[ORM\ManyToOne(targetEntity: UnexposedTargetItem::class)]
private UnexposedTargetItem|null $favourite = null;

public function __construct()
{
$this->items = new ArrayCollection();
}

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

/** @return Collection<int, UnexposedTargetItem> */
public function getItems(): Collection
{
return $this->items;
}

public function getFavourite(): UnexposedTargetItem|null
{
return $this->favourite;
}
}
1 change: 1 addition & 0 deletions test/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
#[GraphQL\Entity(group: 'multiComputedTest')]
#[GraphQL\Entity(group: 'computedFieldNameTest')]
#[GraphQL\Entity(group: 'isMethodTest')]
#[GraphQL\Entity(group: 'entityTest')]
#[ORM\Entity]
class User
{
Expand Down
40 changes: 40 additions & 0 deletions test/Feature/ComputedField/ComputedEntityFieldTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

use ApiSkeletons\Doctrine\ORM\GraphQL\Config;
use ApiSkeletons\Doctrine\ORM\GraphQL\Driver;
use ApiSkeletons\Doctrine\ORM\GraphQL\Event\Metadata as MetadataEvent;
use ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata as MetadataException;
use ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator\Strategy\FieldDefault;
use ApiSkeletons\Doctrine\ORM\GraphQL\Metadata;
use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\ComputedAuthor;
use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\ComputedBook;
Expand All @@ -16,6 +18,7 @@
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;
use League\Event\EventDispatcher;

use function assert;

Expand Down Expand Up @@ -213,4 +216,41 @@ public function testEntityNotExposedInTheGroupIsAnError(): void

$driver->get(Metadata::class);
}

/**
* The entities are checked after the metadata.build event, so a listener
* may expose the entity a computed field returns
*/
public function testListenerMayExposeTheEntity(): void
{
$driver = new Driver($this->getEntityManager(), new Config(['group' => 'ComputedEntityUnexposed']));

$driver->get(EventDispatcher::class)->subscribeTo(
'metadata.build',
static function (MetadataEvent $event): void {
$event->getMetadata()[ComputedAuthor::class] = [
'entityClass' => ComputedAuthor::class,
'extractByValue' => true,
'limit' => 0,
'description' => null,
'excludeFilters' => [],
'typeName' => 'ComputedAuthor',
'fields' => [
'name' => [
'alias' => null,
'description' => null,
'type' => 'string',
'hydratorStrategy' => FieldDefault::class,
'excludeFilters' => [],
],
],
];
},
);

$book = $driver->type(ComputedBook::class);
assert($book instanceof ObjectType);

$this->assertSame($driver->type(ComputedAuthor::class), $book->getField('writer')->getType());
}
}
10 changes: 10 additions & 0 deletions test/Feature/Metadata/CachingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ public function testStaticMetadata(): void
],
],
],
// Exposed as the recordings association refers to it
'ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\Recording' => [
'entityClass' => 'ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\Recording',
'extractByValue' => true,
'limit' => 0,
'description' => '',
'excludeFilters' => [],
'typeName' => 'ApiSkeletonsTest_Doctrine_ORM_GraphQL_Entity_Recording_StaticMetadata',
'fields' => [],
],
];

$driver = new Driver($this->getEntityManager(), new Config(['group' => 'StaticMetadata']), $metadata);
Expand Down
Loading
Loading