Skip to content

Commit b8cbdb1

Browse files
authored
fix: search on nested sub-entity that doesn't use "id" as its ORM identifier (#5623)
Co-authored-by: Manuel Rossard <[email protected]>
1 parent f794201 commit b8cbdb1

File tree

13 files changed

+352
-4
lines changed

13 files changed

+352
-4
lines changed

features/doctrine/search_filter.feature

+9
Original file line numberDiff line numberDiff line change
@@ -1024,3 +1024,12 @@ Feature: Search filter on collections
10241024
Then the response status code should be 200
10251025
And the response should be in JSON
10261026
And the JSON node "hydra:totalItems" should be equal to 1
1027+
1028+
@!mongodb
1029+
@createSchema
1030+
Scenario: Search on nested sub-entity that doesn't use "id" as its ORM identifier
1031+
Given there is a dummy entity with a sub entity with id "stringId" and name "someName"
1032+
When I send a "GET" request to "/dummy_with_subresource?subEntity=/dummy_subresource/stringId"
1033+
Then the response status code should be 200
1034+
And the response should be in JSON
1035+
And the JSON node "hydra:totalItems" should be equal to 1

src/Doctrine/Common/Filter/SearchFilterTrait.php

+7-1
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,13 @@ protected function getIdFromValue(string $value): mixed
124124
$iriConverter = $this->getIriConverter();
125125
$item = $iriConverter->getResourceFromIri($value, ['fetch_data' => false]);
126126

127-
return $this->getPropertyAccessor()->getValue($item, 'id');
127+
if (null === $this->identifiersExtractor) {
128+
return $this->getPropertyAccessor()->getValue($item, 'id');
129+
}
130+
131+
$identifiers = $this->identifiersExtractor->getIdentifiersFromItem($item);
132+
133+
return 1 === \count($identifiers) ? array_pop($identifiers) : $identifiers;
128134
} catch (InvalidArgumentException) {
129135
// Do nothing, return the raw value
130136
}

src/Doctrine/Orm/Filter/SearchFilter.php

+4-1
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB
100100
if ($metadata->hasField($field)) {
101101
if ('id' === $field) {
102102
$values = array_map($this->getIdFromValue(...), $values);
103+
// todo: handle composite IDs
103104
}
104105

105106
if (!$this->hasValidValues($values, $this->getDoctrineFieldType($property, $resourceClass))) {
@@ -121,9 +122,11 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB
121122
}
122123

123124
$values = array_map($this->getIdFromValue(...), $values);
125+
// todo: handle composite IDs
124126

125127
$associationResourceClass = $metadata->getAssociationTargetClass($field);
126-
$associationFieldIdentifier = $metadata->getIdentifierFieldNames()[0];
128+
$associationMetadata = $this->getClassMetadata($associationResourceClass);
129+
$associationFieldIdentifier = $associationMetadata->getIdentifierFieldNames()[0];
127130
$doctrineTypeField = $this->getDoctrineFieldType($associationFieldIdentifier, $associationResourceClass);
128131

129132
if (!$this->hasValidValues($values, $doctrineTypeField)) {

src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@
116116
<service id="api_platform.doctrine_mongodb.odm.search_filter" class="ApiPlatform\Doctrine\Odm\Filter\SearchFilter" public="false" abstract="true">
117117
<argument type="service" id="doctrine_mongodb" />
118118
<argument type="service" id="api_platform.iri_converter" />
119-
<argument type="service" id="api_platform.identifiers_extractor.cached" on-invalid="ignore" />
119+
<argument type="service" id="api_platform.identifiers_extractor" on-invalid="ignore" />
120120
<argument type="service" id="api_platform.property_accessor" />
121121
<argument type="service" id="logger" on-invalid="ignore" />
122122
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore"/>

src/Symfony/Bundle/Resources/config/doctrine_orm.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@
152152
<argument type="service" id="api_platform.iri_converter" />
153153
<argument type="service" id="api_platform.property_accessor" />
154154
<argument type="service" id="logger" on-invalid="ignore" />
155-
<argument key="$identifiersExtractor" type="service" id="api_platform.identifiers_extractor.cached" on-invalid="ignore" />
155+
<argument key="$identifiersExtractor" type="service" id="api_platform.identifiers_extractor" on-invalid="ignore" />
156156
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore" />
157157
</service>
158158
<service id="ApiPlatform\Doctrine\Orm\Filter\SearchFilter" alias="api_platform.doctrine.orm.search_filter" />

tests/Behat/DoctrineContext.php

+16
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,10 @@
128128
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyPassenger;
129129
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProduct;
130130
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty;
131+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummySubEntity;
131132
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceNotApiResourceChild;
132133
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel;
134+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyWithSubEntity;
133135
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy;
134136
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy;
135137
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EntityClassWithDateTime;
@@ -2139,6 +2141,20 @@ public function thereIsAResourceUsingEntityClassAndDateTime(): void
21392141
$this->manager->flush();
21402142
}
21412143

2144+
/**
2145+
* @Given there is a dummy entity with a sub entity with id :strId and name :name
2146+
*/
2147+
public function thereIsADummyWithSubEntity(string $strId, string $name): void
2148+
{
2149+
$subEntity = new DummySubEntity($strId, $name);
2150+
$mainEntity = new DummyWithSubEntity();
2151+
$mainEntity->setSubEntity($subEntity);
2152+
$mainEntity->setName('main');
2153+
$this->manager->persist($subEntity);
2154+
$this->manager->persist($mainEntity);
2155+
$this->manager->flush();
2156+
}
2157+
21422158
private function isOrm(): bool
21432159
{
21442160
return null !== $this->schemaTool;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605;
15+
16+
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
17+
use ApiPlatform\Doctrine\Orm\State\Options;
18+
use ApiPlatform\Metadata\ApiFilter;
19+
use ApiPlatform\Metadata\ApiProperty;
20+
use ApiPlatform\Metadata\ApiResource;
21+
use ApiPlatform\Metadata\Get;
22+
use ApiPlatform\Metadata\GetCollection;
23+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyWithSubEntity;
24+
use ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5605\MainResourceProvider;
25+
26+
#[ApiResource(
27+
operations : [
28+
new Get(uriTemplate: '/dummy_with_subresource/{id}', uriVariables: ['id']),
29+
new GetCollection(uriTemplate: '/dummy_with_subresource'),
30+
],
31+
provider : MainResourceProvider::class,
32+
stateOptions: new Options(entityClass: DummyWithSubEntity::class)
33+
)]
34+
#[ApiFilter(SearchFilter::class, properties: ['subEntity'])]
35+
class MainResource
36+
{
37+
#[ApiProperty(identifier: true)]
38+
public int $id;
39+
public string $name;
40+
public SubResource $subResource;
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605;
15+
16+
use ApiPlatform\Doctrine\Orm\State\Options;
17+
use ApiPlatform\Metadata\ApiProperty;
18+
use ApiPlatform\Metadata\ApiResource;
19+
use ApiPlatform\Metadata\Get;
20+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummySubEntity;
21+
use ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5605\SubResourceProvider;
22+
23+
#[ApiResource(
24+
operations : [
25+
new Get(
26+
uriTemplate: '/dummy_subresource/{strId}',
27+
uriVariables: ['strId']
28+
),
29+
],
30+
provider: SubResourceProvider::class,
31+
stateOptions: new Options(entityClass: DummySubEntity::class)
32+
)]
33+
class SubResource
34+
{
35+
#[ApiProperty(identifier: true)]
36+
public string $strId;
37+
38+
public string $name;
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use Doctrine\ORM\Mapping as ORM;
17+
18+
#[ORM\Entity]
19+
class DummySubEntity
20+
{
21+
#[ORM\Id]
22+
#[ORM\Column(type: 'string')]
23+
private string $strId;
24+
25+
#[ORM\Column]
26+
private string $name;
27+
28+
#[ORM\OneToOne(inversedBy: 'subEntity', cascade: ['persist'])]
29+
private ?DummyWithSubEntity $mainEntity = null;
30+
31+
public function __construct($strId, $name)
32+
{
33+
$this->strId = $strId;
34+
$this->name = $name;
35+
}
36+
37+
public function getStrId(): string
38+
{
39+
return $this->strId;
40+
}
41+
42+
public function getMainEntity(): ?DummyWithSubEntity
43+
{
44+
return $this->mainEntity;
45+
}
46+
47+
public function setMainEntity(DummyWithSubEntity $mainEntity): void
48+
{
49+
$this->mainEntity = $mainEntity;
50+
}
51+
52+
public function getName(): string
53+
{
54+
return $this->name;
55+
}
56+
57+
public function setName(string $name): void
58+
{
59+
$this->name = $name;
60+
}
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use Doctrine\ORM\Mapping as ORM;
17+
18+
#[ORM\Entity]
19+
class DummyWithSubEntity
20+
{
21+
#[ORM\Id]
22+
#[ORM\Column(type: 'integer')]
23+
#[ORM\GeneratedValue(strategy: 'AUTO')]
24+
private int $id;
25+
26+
#[ORM\Column]
27+
private string $name;
28+
29+
#[ORM\OneToOne(mappedBy: 'mainEntity', cascade: ['persist'], fetch: 'EAGER')]
30+
private ?DummySubEntity $subEntity = null;
31+
32+
public function getId(): int
33+
{
34+
return $this->id;
35+
}
36+
37+
public function getName(): string
38+
{
39+
return $this->name;
40+
}
41+
42+
public function setName(string $name): void
43+
{
44+
$this->name = $name;
45+
}
46+
47+
public function getSubEntity(): ?DummySubEntity
48+
{
49+
return $this->subEntity;
50+
}
51+
52+
public function setSubEntity(?DummySubEntity $subEntity): void
53+
{
54+
if (null !== $subEntity && $subEntity->getMainEntity() !== $this) {
55+
$subEntity->setMainEntity($this);
56+
}
57+
58+
$this->subEntity = $subEntity;
59+
}
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5605;
15+
16+
use ApiPlatform\Metadata\Get;
17+
use ApiPlatform\Metadata\Operation;
18+
use ApiPlatform\State\ProviderInterface;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605\MainResource;
20+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605\SubResource;
21+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyWithSubEntity;
22+
23+
class MainResourceProvider implements ProviderInterface
24+
{
25+
public function __construct(private readonly ProviderInterface $itemProvider, private readonly ProviderInterface $collectionProvider)
26+
{
27+
}
28+
29+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
30+
{
31+
if ($operation instanceof Get) {
32+
/**
33+
* @var DummyWithSubEntity $entity
34+
*/
35+
$entity = $this->itemProvider->provide($operation, $uriVariables, $context);
36+
37+
return $this->getResource($entity);
38+
}
39+
$resources = [];
40+
$entities = $this->collectionProvider->provide($operation, $uriVariables, $context);
41+
foreach ($entities as $entity) {
42+
$resources[] = $this->getResource($entity);
43+
}
44+
45+
return $resources;
46+
}
47+
48+
protected function getResource(DummyWithSubEntity $entity): MainResource
49+
{
50+
$resource = new MainResource();
51+
$resource->name = $entity->getName();
52+
$resource->id = $entity->getId();
53+
$resource->subResource = new SubResource();
54+
$resource->subResource->name = $entity->getSubEntity()->getName();
55+
$resource->subResource->strId = $entity->getSubEntity()->getStrId();
56+
57+
return $resource;
58+
}
59+
}

0 commit comments

Comments
 (0)