Skip to content

Commit

Permalink
feat: Add IriOnly Option on Collection properties
Browse files Browse the repository at this point in the history
  • Loading branch information
GregoireHebert committed Jul 17, 2023
1 parent 27f2096 commit 0a4f1b4
Show file tree
Hide file tree
Showing 12 changed files with 493 additions and 3 deletions.
34 changes: 34 additions & 0 deletions features/jsonld/iri_only.feature
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,37 @@ Feature: JSON-LD using iri_only parameter
"hydra:totalItems": 3
}
"""

Scenario: Retrieve Resource with iriOnly collection Property
Given there are propertyCollectionIriOnly with relations
When I send a "GET" request to "/property_collection_iri_onlies"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON should be valid according to this schema:
"""
{
"hydra:member": [
{
"@id": "/property_collection_iri_onlies/1",
"@type": "PropertyCollectionIriOnly",
"propertyCollectionIriOnlyRelation": "/property_collection_iri_only_relations",
"iterableIri": "/property_collection_iri_only_relations"
},
],
}
"""
When I send a "GET" request to "/property_collection_iri_onlies/1"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON should be valid according to this schema:
"""
{
"@context": "/contexts/PropertyCollectionIriOnly",
"@id": "/property_collection_iri_onlies/1",
"@type": "PropertyCollectionIriOnly",
"propertyCollectionIriOnlyRelation": "/property_collection_iri_only_relations",
"iterableIri": "/property_collection_iri_only_relations"
}
"""
3 changes: 2 additions & 1 deletion src/Doctrine/Orm/Extension/EagerLoadingExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,9 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt
}

$fetchEager = $propertyMetadata->getFetchEager();
$iriOnly = $propertyMetadata->getIriOnly();

if (false === $fetchEager) {
if (false === $fetchEager || true === $iriOnly) {
continue;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public function create(string $resourceClass, string $property, array $options =

$propertySchema = $propertyMetadata->getSchema() ?? [];

if (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) {
if (true === $propertyMetadata->getIriOnly() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable())) {
$propertySchema['readOnly'] = true;
}

Expand Down Expand Up @@ -124,6 +124,11 @@ public function create(string $resourceClass, string $property, array $options =
$propertySchema['owl:maxCardinality'] = 1;
}

if ($isCollection && $propertyMetadata->getIriOnly()){
$keyType = null;
$isCollection = false;
}

$propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $propertyMetadata->isReadableLink());
if (!\in_array($propertyType, $valueSchema, true)) {
$valueSchema[] = $propertyType;
Expand Down
20 changes: 19 additions & 1 deletion src/Metadata/ApiProperty.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ final class ApiProperty
* @param string[] $types the RDF types of this property
* @param string[] $iris
* @param Type[] $builtinTypes
* @param bool[] $iriOnly Whether to return the subRessource collection IRI instead of an iterable of IRI.
*/
public function __construct(
private ?string $description = null,
Expand Down Expand Up @@ -69,7 +70,8 @@ public function __construct(
private ?bool $initializable = null,
private $iris = null,
private ?bool $genId = null,
private array $extraProperties = []
private array $extraProperties = [],
private ?bool $iriOnly = null,
) {
if (\is_string($types)) {
$this->types = (array) $types;
Expand Down Expand Up @@ -420,4 +422,20 @@ public function withGenId(bool $genId): self

return $metadata;
}

/**
* Whether to return the subRessource collection IRI instead of an iterable of IRI.
*/
public function getIriOnly()
{
return $this->iriOnly;
}

public function withIriOnly(bool $iriOnly): self
{
$metadata = clone $this;
$metadata->iriOnly = $iriOnly;

return $metadata;
}
}
8 changes: 8 additions & 0 deletions src/Serializer/AbstractItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use ApiPlatform\Exception\ItemNotFoundException;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
Expand Down Expand Up @@ -623,6 +624,13 @@ protected function getAttributeValue(object $object, string $attribute, string $
$childContext = $this->createChildContext($context, $attribute, $format);
unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);

if (true === $propertyMetadata->getIriOnly()) {
$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(null, true);
if ($operation instanceof GetCollection) {
return $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
}
}

return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
}

Expand Down
20 changes: 20 additions & 0 deletions tests/Behat/DoctrineContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
use ApiPlatform\Tests\Fixtures\TestBundle\Document\Pet as PetDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\Product as ProductDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\Program as ProgramDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyCollectionIriOnly as PropertyCollectionIriOnlyDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyCollectionIriOnlyRelation as PropertyCollectionIriOnlyRelationDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\Question as QuestionDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedOwnedDummy as RelatedOwnedDummyDocument;
Expand Down Expand Up @@ -159,6 +161,8 @@
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Pet;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Product;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Program;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Question;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RamseyUuidDummy;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy;
Expand Down Expand Up @@ -1945,6 +1949,22 @@ public function thereAreIriOnlyDummies(int $nb): void
$this->manager->flush();
}

/**
* @Given there are propertyCollectionIriOnly with relations
*/
public function thereAreIriOnlyCollections(): void
{
$propertyCollectionIriOnlyRelation = $this->isOrm() ? new PropertyCollectionIriOnlyRelation() : new PropertyCollectionIriOnlyRelationDocument();
$propertyCollectionIriOnlyRelation->name = 'relation';

$propertyCollectionIriOnly = $this->isOrm() ? new PropertyCollectionIriOnly() : new PropertyCollectionIriOnlyDocument();
$propertyCollectionIriOnly->addPropertyCollectionIriOnlyRelation($propertyCollectionIriOnlyRelation);

$this->manager->persist($propertyCollectionIriOnly);
$this->manager->persist($propertyCollectionIriOnlyRelation);
$this->manager->flush();
}

/**
* @Given there are :nb absoluteUrlDummy objects with a related absoluteUrlRelationDummy
*/
Expand Down
37 changes: 37 additions & 0 deletions tests/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConcreteDummy;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UnknownDummy;
use Doctrine\ORM\EntityManager;
Expand Down Expand Up @@ -884,4 +886,39 @@ public function testApplyToCollectionWithAReadableButNotFetchEagerProperty(): vo
$eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30);
$eagerExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(normalizationContext: [AbstractNormalizer::GROUPS => 'foo']));
}

public function testAvoidFetchCollectionOnIriOnlyProperty(): void
{
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);

$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
$relationPropertyMetadata = new ApiProperty();
$relationPropertyMetadata = $relationPropertyMetadata->withFetchEager(true);
$relationPropertyMetadata = $relationPropertyMetadata->withReadableLink(true);
$relationPropertyMetadata = $relationPropertyMetadata->withReadable(true);
$relationPropertyMetadata = $relationPropertyMetadata->withIriOnly(true);

$propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'propertyCollectionIriOnlyRelation', ['serializer_groups' => ['read'], 'normalization_groups' => 'read'])->willReturn($relationPropertyMetadata)->shouldBeCalled();

$queryBuilderProphecy = $this->prophesize(QueryBuilder::class);

$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
$classMetadataProphecy->associationMappings = [
'propertyCollectionIriOnlyRelation' => ['fetch' => ClassMetadataInfo::FETCH_EAGER, 'joinColumns' => [['nullable' => true]], 'targetEntity' => PropertyCollectionIriOnlyRelation::class],
];

$emProphecy = $this->prophesize(EntityManager::class);
$emProphecy->getClassMetadata(PropertyCollectionIriOnly::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal());
$emProphecy->getClassMetadata(PropertyCollectionIriOnlyRelation::class)->shouldNotBecalled();

$queryBuilderProphecy->getRootAliases()->willReturn(['o']);
$queryBuilderProphecy->getEntityManager()->willReturn($emProphecy);

$queryBuilderProphecy->leftJoin('o.propertyCollectionIriOnlyRelation', 'propertyCollectionIriOnlyRelation_a1')->shouldNotBeCalled();
$queryBuilderProphecy->addSelect('propertyCollectionIriOnlyRelation_a1')->shouldNotBeCalled();

$queryBuilder = $queryBuilderProphecy->reveal();
$eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30);
$eagerExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), PropertyCollectionIriOnly::class, new GetCollection(normalizationContext: [AbstractNormalizer::GROUPS => 'read']));
}
}
90 changes: 90 additions & 0 deletions tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnly.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace ApiPlatform\Tests\Fixtures\TestBundle\Document;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
use Symfony\Component\Serializer\Annotation\Groups;

/**
* Assert that a property being a collection set with ApiProperty::iriOnly to true returns only the IRI of the collection
*/
#[Get(normalizationContext: ['groups' => ['read']]), GetCollection(normalizationContext: ['groups' => ['read']]), Post]
#[ODM\Document]
class PropertyCollectionIriOnly
{
#[ODM\Id(strategy: 'INCREMENT', type: 'int')]
private ?int $id = null;

#[ODM\ReferenceMany(targetDocument: PropertyCollectionIriOnlyRelation::class)]
#[ApiProperty(iriOnly: true)]
#[Groups('read')]
private Collection $propertyCollectionIriOnlyRelation;

/**
* @var iterable<int, PropertyCollectionIriOnlyRelation> $iterableIri
*/
#[ApiProperty(iriOnly: true)]
#[Groups('read')]
private array $iterableIri = [];

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

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

/**
* @return Collection<int, PropertyCollectionIriOnlyRelation>
*/
public function getPropertyCollectionIriOnlyRelation(): Collection
{
return $this->propertyCollectionIriOnlyRelation;
}

public function addPropertyCollectionIriOnlyRelation(PropertyCollectionIriOnlyRelation $propertyCollectionIriOnlyRelation): self
{
if (!$this->propertyCollectionIriOnlyRelation->contains($propertyCollectionIriOnlyRelation)) {
$this->propertyCollectionIriOnlyRelation->add($propertyCollectionIriOnlyRelation);
$propertyCollectionIriOnlyRelation->setPropertyCollectionIriOnly($this);
}

return $this;
}

public function removePropertyCollectionIriOnlyRelation(PropertyCollectionIriOnlyRelation $propertyCollectionIriOnlyRelation): self
{
if ($this->propertyCollectionIriOnlyRelation->removeElement($propertyCollectionIriOnlyRelation)) {
// set the owning side to null (unless already changed)
if ($propertyCollectionIriOnlyRelation->getPropertyCollectionIriOnly() === $this) {
$propertyCollectionIriOnlyRelation->setPropertyCollectionIriOnly(null);
}
}

return $this;
}

/**
* @return array<int, PropertyCollectionIriOnlyRelation>
*/
public function getIterableIri(): array
{
$propertyCollectionIriOnlyRelation = new PropertyCollectionIriOnlyRelation();
$propertyCollectionIriOnlyRelation->name = 'Michel';

$this->iterableIri[] = $propertyCollectionIriOnlyRelation;

return $this->iterableIri;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace ApiPlatform\Tests\Fixtures\TestBundle\Document;

use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
use Symfony\Component\Serializer\Annotation\Groups;

#[GetCollection, Post]
#[ODM\Document]
class PropertyCollectionIriOnlyRelation
{
/**
* The entity ID
*/
#[ODM\Id(strategy: 'INCREMENT', type: 'int')]
private ?int $id = null;

#[ODM\Field(type: 'string')]
#[Groups('read')]
public string $name = '';

#[ODM\ReferenceOne(targetDocument: PropertyCollectionIriOnly::class)]
private ?PropertyCollectionIriOnly $propertyCollectionIriOnly = null;

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

/**
* @return PropertyCollectionIriOnly|null
*/
public function getPropertyCollectionIriOnly(): ?PropertyCollectionIriOnly
{
return $this->propertyCollectionIriOnly;
}

/**
* @param PropertyCollectionIriOnly|null $propertyCollectionIriOnly
*/
public function setPropertyCollectionIriOnly(?PropertyCollectionIriOnly $propertyCollectionIriOnly): void
{
$this->propertyCollectionIriOnly = $propertyCollectionIriOnly;
}
}
Loading

0 comments on commit 0a4f1b4

Please sign in to comment.