Skip to content

Commit 5b7bf9f

Browse files
committed
fix(doctrine): reset nested_properties_info for non-nested properties in FreeTextQueryFilter
(#7845) | Q | A | ------------- | --- | Branch? | main | Tickets | Fixes #7845 | License | MIT | Doc PR | ∅ Non-nested properties incorrectly inherited the full nested_properties_info map, causing addNestedParameterJoins to pick up wrong join aliases via reset(). Always override nested_properties_info: scoped entry for nested, empty for non-nested.
1 parent aff1cf2 commit 5b7bf9f

File tree

4 files changed

+236
-6
lines changed

4 files changed

+236
-6
lines changed

src/Doctrine/Orm/Filter/FreeTextQueryFilter.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,12 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q
5555
$subParameter = $parameter->withProperty($property);
5656

5757
$nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? [];
58-
if (isset($nestedPropertiesInfo[$property])) {
59-
$subParameter = $subParameter->withExtraProperties([
60-
...$subParameter->getExtraProperties(),
61-
'nested_properties_info' => [$property => $nestedPropertiesInfo[$property]],
62-
]);
63-
}
58+
$subParameter = $subParameter->withExtraProperties([
59+
...$subParameter->getExtraProperties(),
60+
'nested_properties_info' => isset($nestedPropertiesInfo[$property])
61+
? [$property => $nestedPropertiesInfo[$property]]
62+
: [],
63+
]);
6464

6565
$this->filter->apply(
6666
$qb,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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 ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter;
17+
use ApiPlatform\Doctrine\Orm\Filter\OrFilter;
18+
use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter;
19+
use ApiPlatform\Metadata\ApiResource;
20+
use ApiPlatform\Metadata\GetCollection;
21+
use ApiPlatform\Metadata\QueryParameter;
22+
use Doctrine\ORM\Mapping as ORM;
23+
24+
#[ORM\Entity]
25+
#[ApiResource(
26+
operations: [
27+
new GetCollection(
28+
normalizationContext: ['hydra_prefix' => false],
29+
parameters: [
30+
'search' => new QueryParameter(
31+
filter: new FreeTextQueryFilter(new OrFilter(new PartialSearchFilter(caseSensitive: true))),
32+
properties: ['content', 'tag.content'],
33+
),
34+
],
35+
),
36+
]
37+
)]
38+
class FreeTextArticle
39+
{
40+
#[ORM\Id]
41+
#[ORM\GeneratedValue]
42+
#[ORM\Column(type: 'integer')]
43+
private ?int $id = null;
44+
45+
#[ORM\Column(type: 'string', length: 255)]
46+
private string $content;
47+
48+
#[ORM\ManyToOne(targetEntity: FreeTextTag::class)]
49+
#[ORM\JoinColumn(nullable: true)]
50+
private ?FreeTextTag $tag = null;
51+
52+
public function getId(): ?int
53+
{
54+
return $this->id;
55+
}
56+
57+
public function getContent(): string
58+
{
59+
return $this->content;
60+
}
61+
62+
public function setContent(string $content): self
63+
{
64+
$this->content = $content;
65+
66+
return $this;
67+
}
68+
69+
public function getTag(): ?FreeTextTag
70+
{
71+
return $this->tag;
72+
}
73+
74+
public function setTag(?FreeTextTag $tag): self
75+
{
76+
$this->tag = $tag;
77+
78+
return $this;
79+
}
80+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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 FreeTextTag
20+
{
21+
#[ORM\Id]
22+
#[ORM\GeneratedValue]
23+
#[ORM\Column(type: 'integer')]
24+
private ?int $id = null;
25+
26+
#[ORM\Column(type: 'string', length: 255)]
27+
private string $content;
28+
29+
public function getId(): ?int
30+
{
31+
return $this->id;
32+
}
33+
34+
public function getContent(): string
35+
{
36+
return $this->content;
37+
}
38+
39+
public function setContent(string $content): self
40+
{
41+
$this->content = $content;
42+
43+
return $this;
44+
}
45+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Functional\Parameters;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FreeTextArticle;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FreeTextTag;
19+
use ApiPlatform\Tests\RecreateSchemaTrait;
20+
use ApiPlatform\Tests\SetupClassResourcesTrait;
21+
22+
final class FreeTextQueryFilterNestedTest extends ApiTestCase
23+
{
24+
use RecreateSchemaTrait;
25+
use SetupClassResourcesTrait;
26+
27+
protected static ?bool $alwaysBootKernel = false;
28+
29+
/**
30+
* @return class-string[]
31+
*/
32+
public static function getResources(): array
33+
{
34+
return [FreeTextArticle::class, FreeTextTag::class];
35+
}
36+
37+
/**
38+
* Tests that FreeTextQueryFilter with mixed nested and non-nested properties
39+
* generates correct SQL aliases (non-nested properties should not use the join alias).
40+
*/
41+
public function testMixedNestedAndNonNestedProperties(): void
42+
{
43+
$client = $this->createClient();
44+
45+
// Should match article1 by root content
46+
$response = $client->request('GET', '/free_text_articles?search=root-match')->toArray();
47+
$this->assertJsonContains(['totalItems' => 1]);
48+
49+
// Should match article2 by tag.content
50+
$response = $client->request('GET', '/free_text_articles?search=tag-match')->toArray();
51+
$this->assertJsonContains(['totalItems' => 1]);
52+
53+
// Should match both articles (article1 root content contains "shared", article3 tag content contains "shared")
54+
$response = $client->request('GET', '/free_text_articles?search=shared')->toArray();
55+
$this->assertJsonContains(['totalItems' => 2]);
56+
57+
// Should match nothing
58+
$response = $client->request('GET', '/free_text_articles?search=nonexistent')->toArray();
59+
$this->assertJsonContains(['totalItems' => 0]);
60+
}
61+
62+
protected function setUp(): void
63+
{
64+
$this->recreateSchema([FreeTextArticle::class, FreeTextTag::class]);
65+
$this->loadFixtures();
66+
}
67+
68+
private function loadFixtures(): void
69+
{
70+
$manager = $this->getManager();
71+
72+
$tag1 = new FreeTextTag();
73+
$tag1->setContent('unrelated-tag');
74+
75+
$tag2 = new FreeTextTag();
76+
$tag2->setContent('tag-match-value');
77+
78+
$tag3 = new FreeTextTag();
79+
$tag3->setContent('shared-tag');
80+
81+
$manager->persist($tag1);
82+
$manager->persist($tag2);
83+
$manager->persist($tag3);
84+
85+
// article1: root content matches "root-match" and "shared", tag does not
86+
$article1 = new FreeTextArticle();
87+
$article1->setContent('root-match-shared');
88+
$article1->setTag($tag1);
89+
90+
// article2: root content does not match, but tag matches "tag-match"
91+
$article2 = new FreeTextArticle();
92+
$article2->setContent('nothing-special');
93+
$article2->setTag($tag2);
94+
95+
// article3: root content does not match "shared", but tag matches "shared"
96+
$article3 = new FreeTextArticle();
97+
$article3->setContent('nothing-here');
98+
$article3->setTag($tag3);
99+
100+
$manager->persist($article1);
101+
$manager->persist($article2);
102+
$manager->persist($article3);
103+
$manager->flush();
104+
}
105+
}

0 commit comments

Comments
 (0)