99use ApiPlatform \Doctrine \Orm \Filter \AbstractFilter ;
1010use ApiPlatform \Doctrine \Orm \Util \QueryNameGeneratorInterface ;
1111use ApiPlatform \Metadata \Operation ;
12+ use Chamilo \CoreBundle \Entity \User ;
13+ use Chamilo \CoreBundle \Helpers \AccessUrlHelper ;
1214use Doctrine \ORM \QueryBuilder ;
15+ use Doctrine \Persistence \ManagerRegistry ;
1316use 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