Commit | Line | Data |
---|---|---|
e405006c CW |
1 | <?php |
2 | ||
3 | namespace Civi\Api4\Action\SearchDisplay; | |
4 | ||
5 | use Civi\API\Exception\UnauthorizedException; | |
f28a6f18 | 6 | use Civi\Api4\Generic\Traits\ArrayQueryActionTrait; |
e405006c CW |
7 | use Civi\Api4\SearchDisplay; |
8 | use Civi\Api4\Utils\CoreUtil; | |
9 | ||
10 | /** | |
11 | * Base class for running a search. | |
12 | * | |
ea04af0c CW |
13 | * @method $this setDisplay(array|string $display) |
14 | * @method array|string|null getDisplay() | |
15 | * @method $this setSort(array $sort) | |
16 | * @method array getSort() | |
17 | * @method $this setFilters(array $filters) | |
18 | * @method array getFilters() | |
19 | * @method $this setSeed(string $seed) | |
20 | * @method string getSeed() | |
21 | * @method $this setAfform(string $afform) | |
22 | * @method string getAfform() | |
e405006c CW |
23 | * @package Civi\Api4\Action\SearchDisplay |
24 | */ | |
25 | abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { | |
26 | ||
ea04af0c | 27 | use SavedSearchInspectorTrait; |
e405006c CW |
28 | |
29 | /** | |
30 | * Either the name of the display or an array containing the display definition (for preview mode) | |
ea04af0c CW |
31 | * |
32 | * Leave NULL to use the autogenerated default. | |
33 | * | |
34 | * @var string|array|null | |
e405006c CW |
35 | */ |
36 | protected $display; | |
37 | ||
38 | /** | |
39 | * Array of fields to use for ordering the results | |
40 | * @var array | |
41 | */ | |
42 | protected $sort = []; | |
43 | ||
44 | /** | |
45 | * Search conditions that will be automatically added to the WHERE or HAVING clauses | |
46 | * @var array | |
47 | */ | |
48 | protected $filters = []; | |
49 | ||
b53f1cad CW |
50 | /** |
51 | * Integer used as a seed when ordering by RAND(). | |
52 | * This keeps the order stable enough to use a pager with random sorting. | |
53 | * | |
54 | * @var int | |
55 | */ | |
56 | protected $seed; | |
57 | ||
e405006c CW |
58 | /** |
59 | * Name of Afform, if this display is embedded (used for permissioning) | |
60 | * @var string | |
61 | */ | |
62 | protected $afform; | |
63 | ||
e405006c CW |
64 | /** |
65 | * @var array | |
66 | */ | |
67 | private $_afform; | |
68 | ||
69 | /** | |
70 | * @param \Civi\Api4\Generic\Result $result | |
71 | * @throws UnauthorizedException | |
72 | * @throws \API_Exception | |
73 | */ | |
74 | public function _run(\Civi\Api4\Generic\Result $result) { | |
75 | // Only administrators can use this in unsecured "preview mode" | |
ea04af0c | 76 | if ((is_array($this->savedSearch) || is_array($this->display)) && $this->checkPermissions && !\CRM_Core_Permission::check('administer CiviCRM data')) { |
e405006c CW |
77 | throw new UnauthorizedException('Access denied'); |
78 | } | |
ea04af0c CW |
79 | $this->loadSavedSearch(); |
80 | if (is_string($this->display)) { | |
e405006c CW |
81 | $this->display = SearchDisplay::get(FALSE) |
82 | ->setSelect(['*', 'type:name']) | |
83 | ->addWhere('name', '=', $this->display) | |
84 | ->addWhere('saved_search_id', '=', $this->savedSearch['id']) | |
ea04af0c | 85 | ->execute()->single(); |
e405006c | 86 | } |
ea04af0c CW |
87 | elseif (is_null($this->display)) { |
88 | $this->display = SearchDisplay::getDefault(FALSE) | |
5c952e51 | 89 | ->addSelect('*', 'type:name') |
ea04af0c CW |
90 | ->setSavedSearch($this->savedSearch) |
91 | ->execute()->first(); | |
e405006c CW |
92 | } |
93 | // Displays with acl_bypass must be embedded on an afform which the user has access to | |
94 | if ( | |
95 | $this->checkPermissions && !empty($this->display['acl_bypass']) && | |
96 | !\CRM_Core_Permission::check('all CiviCRM permissions and ACLs') && !$this->loadAfform() | |
97 | ) { | |
98 | throw new UnauthorizedException('Access denied'); | |
99 | } | |
100 | ||
7193d9f6 | 101 | $this->_apiParams['checkPermissions'] = empty($this->display['acl_bypass']); |
1fd2aa71 | 102 | $this->display['settings']['columns'] = $this->display['settings']['columns'] ?? []; |
e405006c CW |
103 | |
104 | $this->processResult($result); | |
105 | } | |
106 | ||
107 | abstract protected function processResult(\Civi\Api4\Generic\Result $result); | |
108 | ||
1fd2aa71 | 109 | /** |
8fd58f56 CW |
110 | * Transforms each row into an array of raw data and an array of formatted columns |
111 | * | |
1fd2aa71 | 112 | * @param \Civi\Api4\Generic\Result $result |
8fd58f56 | 113 | * @return array{data: array, columns: array}[] |
1fd2aa71 CW |
114 | */ |
115 | protected function formatResult(\Civi\Api4\Generic\Result $result): array { | |
8fd58f56 | 116 | $rows = []; |
d4381c0f CW |
117 | $keyName = CoreUtil::getIdFieldName($this->savedSearch['api_entity']); |
118 | foreach ($result as $index => $record) { | |
8fd58f56 CW |
119 | $data = $columns = []; |
120 | foreach ($this->getSelectClause() as $key => $item) { | |
d4381c0f | 121 | $data[$key] = $this->getValue($key, $record, $index); |
1fd2aa71 | 122 | } |
8fd58f56 CW |
123 | foreach ($this->display['settings']['columns'] as $column) { |
124 | $columns[] = $this->formatColumn($column, $data); | |
1fd2aa71 | 125 | } |
f28a6f18 | 126 | $style = $this->getCssStyles($this->display['settings']['cssRules'] ?? [], $data); |
d4381c0f | 127 | $row = [ |
8fd58f56 CW |
128 | 'data' => $data, |
129 | 'columns' => $columns, | |
f28a6f18 | 130 | 'cssClass' => implode(' ', $style), |
8fd58f56 | 131 | ]; |
d4381c0f CW |
132 | if (isset($data[$keyName])) { |
133 | $row['key'] = $data[$keyName]; | |
134 | } | |
135 | $rows[] = $row; | |
1fd2aa71 | 136 | } |
8fd58f56 | 137 | return $rows; |
1fd2aa71 CW |
138 | } |
139 | ||
7d527c18 | 140 | /** |
8fd58f56 CW |
141 | * @param string $key |
142 | * @param array $data | |
143 | * @param int $rowIndex | |
144 | * @return mixed | |
7d527c18 | 145 | */ |
8fd58f56 | 146 | private function getValue($key, $data, $rowIndex) { |
7d527c18 CW |
147 | // Get value from api result unless this is a pseudo-field which gets a calculated value |
148 | switch ($key) { | |
149 | case 'result_row_num': | |
7193d9f6 | 150 | return $rowIndex + 1 + ($this->_apiParams['offset'] ?? 0); |
7d527c18 CW |
151 | |
152 | case 'user_contact_id': | |
8fd58f56 | 153 | return \CRM_Core_Session::getLoggedInContactID(); |
7d527c18 CW |
154 | |
155 | default: | |
8fd58f56 CW |
156 | return $data[$key] ?? NULL; |
157 | } | |
158 | } | |
159 | ||
160 | /** | |
161 | * @param $column | |
162 | * @param $data | |
163 | * @return array{val: mixed, links: array, edit: array, label: string, title: string, image: array, cssClass: string} | |
164 | */ | |
165 | private function formatColumn($column, $data) { | |
166 | $column += ['rewrite' => NULL, 'label' => NULL]; | |
f28a6f18 | 167 | $out = []; |
8fd58f56 CW |
168 | switch ($column['type']) { |
169 | case 'field': | |
170 | if (isset($column['image']) && is_array($column['image'])) { | |
171 | $out['img'] = $this->formatImage($column, $data); | |
172 | $out['val'] = $this->replaceTokens($column['image']['alt'] ?? NULL, $data, 'view'); | |
173 | } | |
174 | elseif ($column['rewrite']) { | |
175 | $out['val'] = $this->replaceTokens($column['rewrite'], $data, 'view'); | |
176 | } | |
177 | else { | |
178 | $out['val'] = $this->formatViewValue($column['key'], $data[$column['key']] ?? NULL); | |
179 | } | |
180 | if ($this->hasValue($column['label']) && (!empty($column['forceLabel']) || $this->hasValue($out['val']))) { | |
181 | $out['label'] = $this->replaceTokens($column['label'], $data, 'view'); | |
182 | } | |
183 | if (isset($column['title']) && strlen($column['title'])) { | |
184 | $out['title'] = $this->replaceTokens($column['title'], $data, 'view'); | |
185 | } | |
ea04af0c CW |
186 | if (!empty($column['link'])) { |
187 | $links = $this->formatFieldLinks($column, $data, $out['val']); | |
188 | if ($links) { | |
189 | $out['links'] = $links; | |
190 | } | |
8fd58f56 CW |
191 | } |
192 | elseif (!empty($column['editable']) && !$column['rewrite']) { | |
ea04af0c CW |
193 | $edit = $this->formatEditableColumn($column, $data); |
194 | if ($edit) { | |
195 | $out['edit'] = $edit; | |
196 | } | |
8fd58f56 CW |
197 | } |
198 | break; | |
199 | ||
200 | case 'links': | |
201 | case 'buttons': | |
202 | case 'menu': | |
203 | $out = $this->formatLinksColumn($column, $data); | |
204 | break; | |
205 | } | |
f28a6f18 | 206 | $cssClass = $this->getCssStyles($column['cssRules'] ?? [], $data); |
8fd58f56 CW |
207 | if (!empty($column['alignment'])) { |
208 | $cssClass[] = $column['alignment']; | |
209 | } | |
210 | if ($cssClass) { | |
211 | $out['cssClass'] = implode(' ', $cssClass); | |
212 | } | |
213 | return $out; | |
214 | } | |
215 | ||
f28a6f18 CW |
216 | /** |
217 | * Evaluates conditional style rules | |
218 | * | |
219 | * Rules are in the format ['css class', 'field_name', 'OPERATOR', 'value'] | |
220 | * | |
221 | * @param array[] $styleRules | |
222 | * @param array $data | |
223 | * @return array | |
224 | */ | |
225 | protected function getCssStyles(array $styleRules, array $data) { | |
226 | $classes = []; | |
227 | foreach ($styleRules as $clause) { | |
228 | $cssClass = $clause[0] ?? ''; | |
229 | if ($cssClass) { | |
230 | $condition = $this->getCssRuleCondition($clause); | |
231 | if (is_null($condition[0]) || (ArrayQueryActionTrait::filterCompare($data, $condition))) { | |
232 | $classes[] = $cssClass; | |
233 | } | |
234 | } | |
235 | } | |
236 | return $classes; | |
237 | } | |
238 | ||
239 | /** | |
240 | * Returns the condition of a cssRules | |
241 | * | |
242 | * @param array $clause | |
243 | * @return array | |
244 | */ | |
245 | protected function getCssRuleCondition($clause) { | |
246 | $fieldKey = $clause[1] ?? NULL; | |
247 | // For fields used in group by, add aggregation and change operator from = to CONTAINS | |
248 | // FIXME: This assumes the operator is always set to '=', which so far is all the admin UI supports. | |
249 | // That's only a safe assumption as long as the admin UI doesn't have an operator selector. | |
250 | // @see ang/crmSearchAdmin/displays/common/searchAdminCssRules.html | |
251 | if ($fieldKey && $this->canAggregate($fieldKey)) { | |
252 | $clause[2] = 'CONTAINS'; | |
253 | $fieldKey = 'GROUP_CONCAT_' . str_replace(['.', ':'], '_', $clause[1]); | |
254 | } | |
255 | return [$fieldKey, $clause[2] ?? 'IS NOT EMPTY', $clause[3] ?? NULL]; | |
256 | } | |
257 | ||
258 | /** | |
259 | * Return fields needed for the select clause by a set of css rules | |
260 | * | |
261 | * @param array $cssRules | |
262 | * @return array | |
263 | */ | |
264 | protected function getCssRulesSelect($cssRules) { | |
265 | $select = []; | |
266 | foreach ($cssRules as $clause) { | |
267 | $fieldKey = $clause[1] ?? NULL; | |
268 | if ($fieldKey) { | |
269 | // For fields used in group by, add aggregation | |
270 | $select[] = $this->canAggregate($fieldKey) ? "GROUP_CONCAT($fieldKey) AS GROUP_CONCAT_" . str_replace(['.', ':'], '_', $fieldKey) : $fieldKey; | |
271 | } | |
272 | } | |
273 | return $select; | |
274 | } | |
275 | ||
8fd58f56 CW |
276 | /** |
277 | * Format a field value as links | |
278 | * @param $column | |
279 | * @param $data | |
280 | * @param $value | |
281 | * @return array{text: string, url: string, target: string}[] | |
282 | */ | |
283 | private function formatFieldLinks($column, $data, $value): array { | |
284 | $links = []; | |
285 | if (!empty($column['image'])) { | |
286 | $value = ['']; | |
287 | } | |
288 | foreach ((array) $value as $index => $val) { | |
ea04af0c CW |
289 | $path = $this->getLinkPath($column['link'], $data, $index); |
290 | $path = $this->replaceTokens($path, $data, 'url', $index); | |
8fd58f56 CW |
291 | if ($path) { |
292 | $link = [ | |
293 | 'text' => $val, | |
294 | 'url' => $this->getUrl($path), | |
295 | ]; | |
296 | if (!empty($column['link']['target'])) { | |
297 | $link['target'] = $column['link']['target']; | |
298 | } | |
299 | $links[] = $link; | |
300 | } | |
301 | } | |
302 | return $links; | |
303 | } | |
304 | ||
305 | /** | |
306 | * Format links for a menu/buttons/links column | |
ea04af0c CW |
307 | * @param array $column |
308 | * @param array $data | |
8fd58f56 CW |
309 | * @return array{text: string, url: string, target: string, style: string, icon: string}[] |
310 | */ | |
311 | private function formatLinksColumn($column, $data): array { | |
312 | $out = ['links' => []]; | |
313 | if (isset($column['text'])) { | |
314 | $out['text'] = $this->replaceTokens($column['text'], $data, 'view'); | |
315 | } | |
316 | foreach ($column['links'] as $item) { | |
ea04af0c | 317 | $path = $this->replaceTokens($this->getLinkPath($item, $data), $data, 'url'); |
8fd58f56 CW |
318 | if ($path) { |
319 | $link = [ | |
320 | 'text' => $this->replaceTokens($item['text'] ?? '', $data, 'view'), | |
321 | 'url' => $this->getUrl($path), | |
322 | ]; | |
323 | foreach (['target', 'style', 'icon'] as $prop) { | |
324 | if (!empty($item[$prop])) { | |
325 | $link[$prop] = $item[$prop]; | |
326 | } | |
327 | } | |
328 | $out['links'][] = $link; | |
329 | } | |
330 | } | |
331 | return $out; | |
332 | } | |
333 | ||
ea04af0c CW |
334 | /** |
335 | * @param array $link | |
336 | * @param array $data | |
337 | * @param int $index | |
338 | * @return string|null | |
339 | */ | |
340 | private function getLinkPath($link, $data = NULL, $index = 0) { | |
341 | $path = $link['path'] ?? NULL; | |
342 | if (!$path && !empty($link['entity']) && !empty($link['action'])) { | |
343 | $entity = $link['entity']; | |
344 | $idField = $idKey = CoreUtil::getIdFieldName($entity); | |
345 | // Hack to support links to relationships | |
346 | if ($entity === 'Relationship') { | |
347 | $entity = 'RelationshipCache'; | |
348 | $idKey = 'relationship_id'; | |
349 | } | |
350 | $path = CoreUtil::getInfoItem($entity, 'paths')[$link['action']] ?? NULL; | |
351 | $prefix = ''; | |
352 | if ($path && !empty($link['join'])) { | |
353 | $prefix = $link['join'] . '.'; | |
354 | } | |
355 | // This is a bit clunky, the function_join_field gets un-munged later by $this->getJoinFromAlias() | |
356 | if ($this->canAggregate($prefix . $idKey)) { | |
357 | $prefix = 'GROUP_CONCAT_' . str_replace('.', '_', $prefix); | |
358 | } | |
359 | if ($prefix) { | |
360 | $path = str_replace('[', '[' . $prefix, $path); | |
361 | } | |
17d7be01 CW |
362 | // Check access for edit/update links |
363 | // (presumably if a record is shown in SearchKit the user already has view access, and the check is expensive) | |
364 | if ($path && isset($data) && $link['action'] !== 'view') { | |
365 | $id = $data[$prefix . $idKey] ?? NULL; | |
366 | $id = is_array($id) ? $id[$index] ?? NULL : $id; | |
367 | if ($id) { | |
368 | $access = civicrm_api4($link['entity'], 'checkAccess', [ | |
369 | 'action' => $link['action'], | |
370 | 'values' => [ | |
371 | $idField => $id, | |
372 | ], | |
373 | ], 0)['access']; | |
374 | if (!$access) { | |
375 | return NULL; | |
376 | } | |
377 | } | |
378 | } | |
ea04af0c CW |
379 | } |
380 | return $path; | |
381 | } | |
382 | ||
8fd58f56 CW |
383 | /** |
384 | * @param string $path | |
385 | * @return string | |
386 | */ | |
387 | private function getUrl(string $path) { | |
388 | if ($path[0] === '/' || strpos($path, 'http://') || strpos($path, 'https://')) { | |
389 | return $path; | |
7d527c18 | 390 | } |
8fd58f56 CW |
391 | // Use absolute urls when downloading spreadsheet |
392 | $absolute = $this->getActionName() === 'download'; | |
393 | return \CRM_Utils_System::url($path, NULL, $absolute, NULL, FALSE); | |
394 | } | |
395 | ||
c0fcc640 CW |
396 | /** |
397 | * @param $column | |
398 | * @param $data | |
399 | * @return array{entity: string, input_type: string, data_type: string, options: bool, serialize: bool, fk_entity: string, value_key: string, record: array, value: mixed}|null | |
400 | */ | |
8fd58f56 | 401 | private function formatEditableColumn($column, $data) { |
c0fcc640 CW |
402 | $editable = $this->getEditableInfo($column['key']); |
403 | if (!empty($data[$editable['id_path']])) { | |
17d7be01 CW |
404 | $access = civicrm_api4($editable['entity'], 'checkAccess', [ |
405 | 'action' => 'update', | |
406 | 'values' => [ | |
407 | $editable['id_key'] => $data[$editable['id_path']], | |
408 | ], | |
409 | ], 0)['access']; | |
410 | if (!$access) { | |
411 | return NULL; | |
412 | } | |
c0fcc640 CW |
413 | $editable['record'] = [ |
414 | $editable['id_key'] => $data[$editable['id_path']], | |
415 | ]; | |
416 | $editable['value'] = $data[$editable['value_path']]; | |
417 | \CRM_Utils_Array::remove($editable, 'id_key', 'id_path', 'value_path'); | |
418 | return $editable; | |
419 | } | |
420 | return NULL; | |
421 | } | |
8fd58f56 | 422 | |
c0fcc640 CW |
423 | /** |
424 | * @param $key | |
425 | * @return array{entity: string, input_type: string, data_type: string, options: bool, serialize: bool, fk_entity: string, value_key: string, value_path: string, id_key: string, id_path: string}|null | |
426 | */ | |
427 | private function getEditableInfo($key) { | |
428 | [$key] = explode(':', $key); | |
429 | $field = $this->getField($key); | |
430 | // If field is an implicit join, use the original fk field | |
431 | if (!empty($field['implicit_join'])) { | |
432 | return $this->getEditableInfo(substr($key, 0, -1 - strlen($field['name']))); | |
433 | } | |
434 | if ($field) { | |
435 | $idKey = CoreUtil::getIdFieldName($field['entity']); | |
436 | $idPath = ($field['explicit_join'] ? $field['explicit_join'] . '.' : '') . $idKey; | |
437 | // Hack to support editing relationships | |
438 | if ($field['entity'] === 'RelationshipCache') { | |
439 | $field['entity'] = 'Relationship'; | |
440 | $idPath = ($field['explicit_join'] ? $field['explicit_join'] . '.' : '') . 'relationship_id'; | |
441 | } | |
442 | return [ | |
443 | 'entity' => $field['entity'], | |
444 | 'input_type' => $field['input_type'], | |
445 | 'data_type' => $field['data_type'], | |
446 | 'options' => !empty($field['options']), | |
447 | 'serialize' => !empty($field['serialize']), | |
448 | 'fk_entity' => $field['fk_entity'], | |
449 | 'value_key' => $field['name'], | |
450 | 'value_path' => $key, | |
451 | 'id_key' => $idKey, | |
452 | 'id_path' => $idPath, | |
453 | ]; | |
454 | } | |
455 | return NULL; | |
8fd58f56 CW |
456 | } |
457 | ||
c0fcc640 CW |
458 | /** |
459 | * @param $column | |
460 | * @param $data | |
461 | * @return array{url: string, width: int, height: int} | |
462 | */ | |
8fd58f56 CW |
463 | private function formatImage($column, $data) { |
464 | $tokenExpr = $column['rewrite'] ?: '[' . $column['key'] . ']'; | |
7d527c18 | 465 | return [ |
73ae4234 | 466 | 'src' => $this->replaceTokens($tokenExpr, $data, 'url'), |
8fd58f56 CW |
467 | 'height' => $column['image']['height'] ?? NULL, |
468 | 'width' => $column['image']['width'] ?? NULL, | |
7d527c18 CW |
469 | ]; |
470 | } | |
471 | ||
8fd58f56 CW |
472 | /** |
473 | * @param string $tokenExpr | |
474 | * @param array $data | |
475 | * @param string $format view|raw|url | |
476 | * @param int $index | |
477 | * @return string | |
478 | */ | |
479 | private function replaceTokens($tokenExpr, $data, $format, $index = 0) { | |
ea04af0c CW |
480 | if ($tokenExpr) { |
481 | foreach ($this->getTokens($tokenExpr) as $token) { | |
482 | $val = $data[$token] ?? NULL; | |
483 | if (isset($val) && $format === 'view') { | |
484 | $val = $this->formatViewValue($token, $val); | |
485 | } | |
486 | $replacement = is_array($val) ? $val[$index] ?? '' : $val; | |
487 | // A missing token value in a url invalidates it | |
488 | if ($format === 'url' && (!isset($replacement) || $replacement === '')) { | |
489 | return NULL; | |
490 | } | |
491 | $tokenExpr = str_replace('[' . $token . ']', $replacement, $tokenExpr); | |
8fd58f56 | 492 | } |
8fd58f56 CW |
493 | } |
494 | return $tokenExpr; | |
495 | } | |
496 | ||
1fd2aa71 CW |
497 | /** |
498 | * Format raw field value according to data type | |
8fd58f56 | 499 | * @param string $key |
1fd2aa71 CW |
500 | * @param mixed $rawValue |
501 | * @return array|string | |
502 | */ | |
8fd58f56 | 503 | protected function formatViewValue($key, $rawValue) { |
1fd2aa71 | 504 | if (is_array($rawValue)) { |
8fd58f56 CW |
505 | return array_map(function($val) use ($key) { |
506 | return $this->formatViewValue($key, $val); | |
1fd2aa71 CW |
507 | }, $rawValue); |
508 | } | |
509 | ||
8fd58f56 CW |
510 | $dataType = $this->getSelectExpression($key)['dataType'] ?? NULL; |
511 | ||
1fd2aa71 CW |
512 | $formatted = $rawValue; |
513 | ||
514 | switch ($dataType) { | |
515 | case 'Boolean': | |
516 | if (is_bool($rawValue)) { | |
517 | $formatted = $rawValue ? ts('Yes') : ts('No'); | |
518 | } | |
519 | break; | |
520 | ||
521 | case 'Money': | |
522 | $formatted = \CRM_Utils_Money::format($rawValue); | |
523 | break; | |
524 | ||
525 | case 'Date': | |
526 | case 'Timestamp': | |
527 | $formatted = \CRM_Utils_Date::customFormat($rawValue); | |
528 | } | |
529 | ||
530 | return $formatted; | |
531 | } | |
532 | ||
e405006c CW |
533 | /** |
534 | * Applies supplied filters to the where clause | |
535 | */ | |
536 | protected function applyFilters() { | |
6cc91745 CW |
537 | // Allow all filters that are included in SELECT clause or are fields on the Afform. |
538 | $allowedFilters = array_merge($this->getSelectAliases(), $this->getAfformFilters()); | |
539 | ||
e405006c CW |
540 | // Ignore empty strings |
541 | $filters = array_filter($this->filters, [$this, 'hasValue']); | |
542 | if (!$filters) { | |
543 | return; | |
544 | } | |
545 | ||
6cc91745 CW |
546 | foreach ($filters as $key => $value) { |
547 | $fieldNames = explode(',', $key); | |
548 | if (in_array($key, $allowedFilters, TRUE) || !array_diff($fieldNames, $allowedFilters)) { | |
549 | $this->applyFilter($fieldNames, $value); | |
e405006c CW |
550 | } |
551 | } | |
552 | } | |
553 | ||
554 | /** | |
555 | * Returns an array of field names or aliases + allowed suffixes from the SELECT clause | |
556 | * @return string[] | |
557 | */ | |
558 | protected function getSelectAliases() { | |
559 | $result = []; | |
560 | $selectAliases = array_map(function($select) { | |
561 | return array_slice(explode(' AS ', $select), -1)[0]; | |
562 | }, $this->savedSearch['api_params']['select']); | |
563 | foreach ($selectAliases as $alias) { | |
564 | [$alias] = explode(':', $alias); | |
565 | $result[] = $alias; | |
566 | foreach (['name', 'label', 'abbr'] as $allowedSuffix) { | |
567 | $result[] = $alias . ':' . $allowedSuffix; | |
568 | } | |
569 | } | |
570 | return $result; | |
571 | } | |
572 | ||
573 | /** | |
6cc91745 CW |
574 | * @param array $fieldNames |
575 | * If multiple field names are given they will be combined in an OR clause | |
e405006c CW |
576 | * @param mixed $value |
577 | */ | |
6cc91745 | 578 | private function applyFilter(array $fieldNames, $value) { |
e405006c CW |
579 | // Global setting determines if % wildcard should be added to both sides (default) or only the end of a search string |
580 | $prefixWithWildcard = \Civi::settings()->get('includeWildCardInName'); | |
581 | ||
6cc91745 CW |
582 | // Based on the first field, decide which clause to add this condition to |
583 | $fieldName = $fieldNames[0]; | |
e405006c CW |
584 | $field = $this->getField($fieldName); |
585 | // If field is not found it must be an aggregated column & belongs in the HAVING clause. | |
586 | if (!$field) { | |
7193d9f6 | 587 | $clause =& $this->_apiParams['having']; |
e405006c CW |
588 | } |
589 | // If field belongs to an EXCLUDE join, it should be added as a join condition | |
590 | else { | |
591 | $prefix = strpos($fieldName, '.') ? explode('.', $fieldName)[0] : NULL; | |
7193d9f6 | 592 | foreach ($this->_apiParams['join'] as $idx => $join) { |
e405006c | 593 | if (($join[1] ?? 'LEFT') === 'EXCLUDE' && (explode(' AS ', $join[0])[1] ?? '') === $prefix) { |
7193d9f6 | 594 | $clause =& $this->_apiParams['join'][$idx]; |
e405006c CW |
595 | } |
596 | } | |
597 | } | |
598 | // Default: add filter to WHERE clause | |
599 | if (!isset($clause)) { | |
7193d9f6 | 600 | $clause =& $this->_apiParams['where']; |
e405006c CW |
601 | } |
602 | ||
6cc91745 CW |
603 | $filterClauses = []; |
604 | ||
605 | foreach ($fieldNames as $fieldName) { | |
606 | $field = $this->getField($fieldName); | |
607 | $dataType = $field['data_type'] ?? NULL; | |
608 | // Array is either associative `OP => VAL` or sequential `IN (...)` | |
609 | if (is_array($value)) { | |
610 | $value = array_filter($value, [$this, 'hasValue']); | |
611 | // If array does not contain operators as keys, assume array of values | |
612 | if (array_diff_key($value, array_flip(CoreUtil::getOperators()))) { | |
613 | // Use IN for regular fields | |
614 | if (empty($field['serialize'])) { | |
615 | $filterClauses[] = [$fieldName, 'IN', $value]; | |
616 | } | |
617 | // Use an OR group of CONTAINS for array fields | |
618 | else { | |
619 | $orGroup = []; | |
620 | foreach ($value as $val) { | |
621 | $orGroup[] = [$fieldName, 'CONTAINS', $val]; | |
622 | } | |
623 | $filterClauses[] = ['OR', $orGroup]; | |
624 | } | |
e405006c | 625 | } |
6cc91745 | 626 | // Operator => Value array |
e405006c | 627 | else { |
6cc91745 CW |
628 | $andGroup = []; |
629 | foreach ($value as $operator => $val) { | |
630 | $andGroup[] = [$fieldName, $operator, $val]; | |
e405006c | 631 | } |
6cc91745 | 632 | $filterClauses[] = ['AND', $andGroup]; |
e405006c CW |
633 | } |
634 | } | |
6cc91745 CW |
635 | elseif (!empty($field['serialize'])) { |
636 | $filterClauses[] = [$fieldName, 'CONTAINS', $value]; | |
637 | } | |
638 | elseif (!empty($field['options']) || in_array($dataType, ['Integer', 'Boolean', 'Date', 'Timestamp'])) { | |
639 | $filterClauses[] = [$fieldName, '=', $value]; | |
640 | } | |
641 | elseif ($prefixWithWildcard) { | |
642 | $filterClauses[] = [$fieldName, 'CONTAINS', $value]; | |
643 | } | |
e405006c | 644 | else { |
6cc91745 | 645 | $filterClauses[] = [$fieldName, 'LIKE', $value . '%']; |
e405006c CW |
646 | } |
647 | } | |
6cc91745 CW |
648 | // Single field |
649 | if (count($filterClauses) === 1) { | |
650 | $clause[] = $filterClauses[0]; | |
e405006c CW |
651 | } |
652 | else { | |
6cc91745 | 653 | $clause[] = ['OR', $filterClauses]; |
e405006c CW |
654 | } |
655 | } | |
656 | ||
657 | /** | |
658 | * Transforms the SORT param (which is expected to be an array of arrays) | |
659 | * to the ORDER BY clause (which is an associative array of [field => DIR] | |
660 | * | |
661 | * @return array | |
662 | */ | |
663 | protected function getOrderByFromSort() { | |
664 | $defaultSort = $this->display['settings']['sort'] ?? []; | |
665 | $currentSort = $this->sort; | |
666 | ||
8fd58f56 | 667 | // Verify requested sort corresponds to sortable columns |
e405006c | 668 | foreach ($this->sort as $item) { |
8fd58f56 CW |
669 | $column = array_column($this->display['settings']['columns'], NULL, 'key')[$item[0]] ?? NULL; |
670 | if (!$column || (isset($column['sortable']) && !$column['sortable'])) { | |
e405006c CW |
671 | $currentSort = NULL; |
672 | } | |
673 | } | |
674 | ||
675 | $orderBy = []; | |
676 | foreach ($currentSort ?: $defaultSort as $item) { | |
2eee3858 CW |
677 | // Apply seed to random sorting |
678 | if ($item[0] === 'RAND()' && isset($this->seed)) { | |
679 | $item[0] = 'RAND(' . $this->seed . ')'; | |
680 | } | |
e405006c CW |
681 | $orderBy[$item[0]] = $item[1]; |
682 | } | |
683 | return $orderBy; | |
684 | } | |
685 | ||
686 | /** | |
687 | * Adds additional fields to the select clause required to render the display | |
688 | * | |
689 | * @param array $apiParams | |
690 | */ | |
691 | protected function augmentSelectClause(&$apiParams): void { | |
4afe3e36 CW |
692 | $existing = array_map(function($item) { |
693 | return explode(' AS ', $item)[1] ?? $item; | |
694 | }, $apiParams['select']); | |
c920297c CW |
695 | $additions = []; |
696 | // Add primary key field if actions are enabled | |
697 | if (!empty($this->display['settings']['actions'])) { | |
698 | $additions = CoreUtil::getInfoItem($this->savedSearch['api_entity'], 'primary_key'); | |
699 | } | |
f28a6f18 CW |
700 | // Add style conditions for the display |
701 | foreach ($this->getCssRulesSelect($this->display['settings']['cssRules'] ?? []) as $addition) { | |
702 | $additions[] = $addition; | |
703 | } | |
e405006c | 704 | $possibleTokens = ''; |
1fd2aa71 | 705 | foreach ($this->display['settings']['columns'] as $column) { |
e405006c | 706 | // Collect display values in which a token is allowed |
ea04af0c CW |
707 | $possibleTokens .= ($column['rewrite'] ?? ''); |
708 | if (!empty($column['link'])) { | |
709 | $possibleTokens .= $this->getLinkPath($column['link']) ?? ''; | |
710 | } | |
711 | foreach ($column['links'] ?? [] as $link) { | |
712 | $possibleTokens .= $link['text'] ?? ''; | |
713 | $possibleTokens .= $this->getLinkPath($link) ?? ''; | |
e405006c CW |
714 | } |
715 | ||
c0fcc640 CW |
716 | // Select id & value for in-place editing |
717 | if (!empty($column['editable'])) { | |
718 | $editable = $this->getEditableInfo($column['key']); | |
719 | if ($editable) { | |
720 | $additions[] = $editable['value_path']; | |
721 | $additions[] = $editable['id_path']; | |
722 | } | |
e405006c | 723 | } |
f28a6f18 CW |
724 | // Add style conditions for the column |
725 | foreach ($this->getCssRulesSelect($column['cssRules'] ?? []) as $addition) { | |
726 | $additions[] = $addition; | |
727 | } | |
e405006c CW |
728 | } |
729 | // Add fields referenced via token | |
8fd58f56 | 730 | $tokens = $this->getTokens($possibleTokens); |
4afe3e36 | 731 | // Only add fields not already in SELECT clause |
8fd58f56 | 732 | $additions = array_diff(array_merge($additions, $tokens), $existing); |
2fe33e6c CW |
733 | // Tokens for aggregated columns start with 'GROUP_CONCAT_' |
734 | foreach ($additions as $index => $alias) { | |
735 | if (strpos($alias, 'GROUP_CONCAT_') === 0) { | |
736 | $additions[$index] = 'GROUP_CONCAT(' . $this->getJoinFromAlias(explode('_', $alias, 3)[2]) . ') AS ' . $alias; | |
737 | } | |
738 | } | |
8fd58f56 | 739 | $this->_selectClause = NULL; |
4afe3e36 | 740 | $apiParams['select'] = array_unique(array_merge($apiParams['select'], $additions)); |
e405006c CW |
741 | } |
742 | ||
8fd58f56 CW |
743 | /** |
744 | * @param string $str | |
745 | */ | |
746 | private function getTokens($str) { | |
747 | $tokens = []; | |
748 | preg_match_all('/\\[([^]]+)\\]/', $str, $tokens); | |
749 | return array_unique($tokens[1]); | |
750 | } | |
751 | ||
2fe33e6c CW |
752 | /** |
753 | * Given an alias like Contact_Email_01_location_type_id | |
754 | * this will return Contact_Email_01.location_type_id | |
755 | * @param string $alias | |
756 | * @return string | |
757 | */ | |
758 | protected function getJoinFromAlias(string $alias) { | |
759 | $result = ''; | |
7193d9f6 | 760 | foreach ($this->_apiParams['join'] as $join) { |
2fe33e6c CW |
761 | $joinName = explode(' AS ', $join[0])[1]; |
762 | if (strpos($alias, $joinName) === 0) { | |
763 | $parsed = $joinName . '.' . substr($alias, strlen($joinName) + 1); | |
764 | // Ensure we are using the longest match | |
765 | if (strlen($parsed) > strlen($result)) { | |
766 | $result = $parsed; | |
767 | } | |
768 | } | |
769 | } | |
f28a6f18 | 770 | return $result ?: $alias; |
2fe33e6c CW |
771 | } |
772 | ||
e405006c CW |
773 | /** |
774 | * Checks if a filter contains a non-empty value | |
775 | * | |
776 | * "Empty" search values are [], '', and NULL. | |
777 | * Also recursively checks arrays to ensure they contain at least one non-empty value. | |
778 | * | |
779 | * @param $value | |
780 | * @return bool | |
781 | */ | |
782 | private function hasValue($value) { | |
783 | return $value !== '' && $value !== NULL && (!is_array($value) || array_filter($value, [$this, 'hasValue'])); | |
784 | } | |
785 | ||
786 | /** | |
787 | * Returns a list of filter fields and directive filters | |
788 | * | |
789 | * Automatically applies directive filters | |
790 | * | |
791 | * @return array | |
792 | */ | |
793 | private function getAfformFilters() { | |
794 | $afform = $this->loadAfform(); | |
795 | if (!$afform) { | |
796 | return []; | |
797 | } | |
798 | // Get afform field filters | |
799 | $filterKeys = array_column(\CRM_Utils_Array::findAll( | |
800 | $afform['layout'] ?? [], | |
801 | ['#tag' => 'af-field'] | |
802 | ), 'name'); | |
803 | // Get filters passed into search display directive from Afform markup | |
804 | $filterAttr = $afform['searchDisplay']['filters'] ?? NULL; | |
805 | if ($filterAttr && is_string($filterAttr) && $filterAttr[0] === '{') { | |
806 | foreach (\CRM_Utils_JS::decode($filterAttr) as $filterKey => $filterVal) { | |
e405006c | 807 | // Automatically apply filters from the markup if they have a value |
4d71b82e CW |
808 | if ($filterVal !== NULL) { |
809 | unset($this->filters[$filterKey]); | |
810 | if ($this->hasValue($filterVal)) { | |
811 | $this->applyFilter(explode(',', $filterKey), $filterVal); | |
812 | } | |
813 | } | |
814 | // If it's a javascript variable it will have come back from decode() as NULL; | |
815 | // whitelist it to allow it to be passed to this api from javascript. | |
816 | else { | |
817 | $filterKeys[] = $filterKey; | |
e405006c CW |
818 | } |
819 | } | |
820 | } | |
821 | return $filterKeys; | |
822 | } | |
823 | ||
824 | /** | |
825 | * Return afform with name specified in api call. | |
826 | * | |
827 | * Verifies the searchDisplay is embedded in the afform and the user has permission to view it. | |
828 | * | |
829 | * @return array|false|null | |
830 | */ | |
831 | private function loadAfform() { | |
832 | // Only attempt to load afform once. | |
833 | if ($this->afform && !isset($this->_afform)) { | |
834 | $this->_afform = FALSE; | |
835 | // Permission checks are enabled in this api call to ensure the user has permission to view the form | |
836 | $afform = \Civi\Api4\Afform::get() | |
837 | ->addWhere('name', '=', $this->afform) | |
838 | ->setLayoutFormat('shallow') | |
839 | ->execute()->first(); | |
840 | // Validate that the afform contains this search display | |
841 | $afform['searchDisplay'] = \CRM_Utils_Array::findAll( | |
842 | $afform['layout'] ?? [], | |
843 | ['#tag' => "{$this->display['type:name']}", 'display-name' => $this->display['name']] | |
844 | )[0] ?? NULL; | |
845 | if ($afform['searchDisplay']) { | |
846 | $this->_afform = $afform; | |
847 | } | |
848 | } | |
849 | return $this->_afform; | |
850 | } | |
851 | ||
7d527c18 CW |
852 | /** |
853 | * Extra calculated fields provided by SearchKit | |
854 | * @return array[] | |
855 | */ | |
856 | public static function getPseudoFields(): array { | |
857 | return [ | |
858 | [ | |
859 | 'name' => 'result_row_num', | |
860 | 'fieldName' => 'result_row_num', | |
861 | 'title' => ts('Row Number'), | |
862 | 'label' => ts('Row Number'), | |
863 | 'description' => ts('Index of each row, starting from 1 on the first page'), | |
864 | 'type' => 'Pseudo', | |
865 | 'data_type' => 'Integer', | |
866 | 'readonly' => TRUE, | |
867 | ], | |
868 | [ | |
869 | 'name' => 'user_contact_id', | |
870 | 'fieldName' => 'result_row_num', | |
871 | 'title' => ts('Current User ID'), | |
872 | 'label' => ts('Current User ID'), | |
873 | 'description' => ts('Contact ID of the current user if logged in'), | |
874 | 'type' => 'Pseudo', | |
875 | 'data_type' => 'Integer', | |
876 | 'readonly' => TRUE, | |
877 | ], | |
878 | ]; | |
879 | } | |
880 | ||
e405006c | 881 | } |