Skip to content

Commit 8bfbba5

Browse files
authored
Merge pull request #7249 from christianbeeznest/GH-3832
User: Fix multi-URL user autocomplete scope - refs #3832
2 parents 9b76bec + 0c08a1f commit 8bfbba5

1 file changed

Lines changed: 148 additions & 20 deletions

File tree

src/CoreBundle/Filter/PartialSearchOrFilter.php

Lines changed: 148 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,58 +9,93 @@
99
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
1010
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
1111
use ApiPlatform\Metadata\Operation;
12+
use Chamilo\CoreBundle\Entity\User;
13+
use Chamilo\CoreBundle\Helpers\AccessUrlHelper;
1214
use Doctrine\ORM\QueryBuilder;
15+
use Doctrine\Persistence\ManagerRegistry;
1316
use InvalidArgumentException;
17+
use Psr\Log\LoggerInterface;
1418

15-
class PartialSearchOrFilter extends AbstractFilter
19+
final class PartialSearchOrFilter extends AbstractFilter
1620
{
21+
/**
22+
* Prevent applying the portal scope multiple times on the same QueryBuilder instance.
23+
* (No unused parameters, no side-effects on the DQL parameter list.)
24+
*
25+
* @var array<int, bool>
26+
*/
27+
private static array $scopeApplied = [];
28+
29+
public function __construct(
30+
ManagerRegistry $managerRegistry,
31+
private readonly AccessUrlHelper $accessUrlHelper,
32+
?LoggerInterface $logger = null,
33+
?array $properties = null
34+
) {
35+
// Ensure "search" is an enabled property and normalize list/associative arrays.
36+
$properties = $this->normalizeProperties($properties);
37+
38+
parent::__construct($managerRegistry, $logger, $properties);
39+
}
40+
41+
public function apply(
42+
QueryBuilder $queryBuilder,
43+
QueryNameGeneratorInterface $queryNameGenerator,
44+
string $resourceClass,
45+
?Operation $operation = null,
46+
array $context = []
47+
): void {
48+
// Always scope users by current portal in a multi-URL install.
49+
$this->applyAccessUrlScopeOnce($queryBuilder, $queryNameGenerator, $resourceClass);
50+
51+
// Keep default behavior: only apply filterProperty() when query params exist.
52+
parent::apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
53+
}
54+
1755
protected function filterProperty(
1856
string $property,
19-
$value,
57+
$value,
2058
QueryBuilder $queryBuilder,
2159
QueryNameGeneratorInterface $queryNameGenerator,
2260
string $resourceClass,
2361
?Operation $operation = null,
2462
array $context = []
2563
): void {
64+
65+
$this->applyAccessUrlScopeOnce($queryBuilder, $queryNameGenerator, $resourceClass);
66+
2667
if ('search' !== $property) {
2768
return;
2869
}
2970

30-
if (empty($value)) {
31-
throw new InvalidArgumentException('The property must not be empty.');
71+
if (!is_string($value) || '' === trim($value)) {
72+
throw new InvalidArgumentException('The "search" filter must not be empty.');
3273
}
3374

3475
$alias = $queryBuilder->getRootAliases()[0];
35-
$valueParameter = ':'.$queryNameGenerator->generateParameterName($property);
36-
$queryBuilder->setParameter($valueParameter, '%'.$value.'%');
3776

38-
$ors = [];
77+
$paramName = $queryNameGenerator->generateParameterName('search');
78+
$queryBuilder->setParameter($paramName, '%'.trim($value).'%');
3979

40-
foreach (array_keys($this->properties) as $field) {
41-
// Detect if field is a relation (e.g. "user.username")
80+
$ors = [];
81+
foreach ($this->getSearchableFields() as $field) {
4282
if (str_contains($field, '.')) {
4383
[$relation, $subField] = explode('.', $field, 2);
4484
$joinAlias = $relation.'_alias';
4585

46-
// Ensure the join is only added once
4786
if (!\in_array($joinAlias, $queryBuilder->getAllAliases(), true)) {
4887
$queryBuilder->leftJoin("$alias.$relation", $joinAlias);
4988
}
5089

51-
$ors[] = $queryBuilder->expr()->like(
52-
"$joinAlias.$subField",
53-
$valueParameter
54-
);
90+
$ors[] = $queryBuilder->expr()->like("$joinAlias.$subField", ':'.$paramName);
5591
} else {
56-
$ors[] = $queryBuilder->expr()->like(
57-
"$alias.$field",
58-
$valueParameter
59-
);
92+
$ors[] = $queryBuilder->expr()->like("$alias.$field", ':'.$paramName);
6093
}
6194
}
6295

63-
$queryBuilder->andWhere($queryBuilder->expr()->orX(...$ors));
96+
if (!empty($ors)) {
97+
$queryBuilder->andWhere($queryBuilder->expr()->orX(...$ors));
98+
}
6499
}
65100

66101
public function getDescription(string $resourceClass): array
@@ -70,8 +105,101 @@ public function getDescription(string $resourceClass): array
70105
'property' => null,
71106
'type' => 'string',
72107
'required' => false,
73-
'description' => 'It does a "Search OR" using LIKE %%text%% on the listed fields (supports nested like user.username)',
108+
'description' => 'Search OR/LIKE across configured fields and scopes results to the current portal when multi-URL is enabled.',
74109
],
75110
];
76111
}
112+
113+
/**
114+
* @return array<string, mixed>|null
115+
*/
116+
private function normalizeProperties(?array $properties): ?array
117+
{
118+
if (null === $properties) {
119+
return null;
120+
}
121+
122+
// If a numeric list was provided (['username','firstname',...]),
123+
// convert it into an associative map: ['username' => null, ...]
124+
if (\function_exists('array_is_list') && array_is_list($properties)) {
125+
$normalized = [];
126+
foreach ($properties as $field) {
127+
if (is_string($field) && '' !== trim($field)) {
128+
$normalized[trim($field)] = null;
129+
}
130+
}
131+
$properties = $normalized;
132+
}
133+
134+
// Ensure "search" is enabled, otherwise ApiPlatform may ignore ?search=...
135+
if (!array_key_exists('search', $properties)) {
136+
$properties['search'] = null;
137+
}
138+
139+
return $properties;
140+
}
141+
142+
/**
143+
* @return string[]
144+
*/
145+
private function getSearchableFields(): array
146+
{
147+
// Default fallback if no properties were configured.
148+
if (null === $this->properties) {
149+
return ['username', 'firstname', 'lastname'];
150+
}
151+
152+
$fields = array_keys($this->properties);
153+
154+
// "search" is not a field, it is the filter parameter.
155+
$fields = array_values(array_filter(
156+
$fields,
157+
static fn (string $f): bool => 'search' !== $f
158+
));
159+
160+
return !empty($fields) ? $fields : ['username', 'firstname', 'lastname'];
161+
}
162+
163+
private function applyAccessUrlScopeOnce(
164+
QueryBuilder $queryBuilder,
165+
QueryNameGeneratorInterface $queryNameGenerator,
166+
string $resourceClass
167+
): void {
168+
if (User::class !== $resourceClass) {
169+
return;
170+
}
171+
172+
if (!$this->accessUrlHelper->isMultiple()) {
173+
return;
174+
}
175+
176+
$qbId = spl_object_id($queryBuilder);
177+
if (isset(self::$scopeApplied[$qbId])) {
178+
return;
179+
}
180+
self::$scopeApplied[$qbId] = true;
181+
182+
$currentUrl = $this->accessUrlHelper->getCurrent();
183+
if (null === $currentUrl || null === $currentUrl->getId()) {
184+
return;
185+
}
186+
187+
$alias = $queryBuilder->getRootAliases()[0];
188+
189+
// Join User -> portals (AccessUrlRelUser) -> url (AccessUrl)
190+
$portalsAlias = $queryNameGenerator->generateJoinAlias('portals');
191+
$urlAlias = $queryNameGenerator->generateJoinAlias('accessUrl');
192+
193+
$queryBuilder->innerJoin("$alias.portals", $portalsAlias);
194+
$queryBuilder->innerJoin("$portalsAlias.url", $urlAlias);
195+
196+
// Compare by ID to avoid edge cases with entity object comparison.
197+
$paramName = $queryNameGenerator->generateParameterName('currentAccessUrlId');
198+
199+
$queryBuilder
200+
->andWhere("$urlAlias.id = :$paramName")
201+
->setParameter($paramName, $currentUrl->getId())
202+
->distinct()
203+
;
204+
}
77205
}

0 commit comments

Comments
 (0)