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