SearchKit - Fix display of image fields
[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;
f28a6f18 6use Civi\Api4\Generic\Traits\ArrayQueryActionTrait;
e405006c
CW
7use Civi\Api4\SearchDisplay;
8use 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 */
25abstract 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}