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