3 namespace Civi\Api4\Action\SearchDisplay
;
5 use Civi\API\Exception\UnauthorizedException
;
6 use Civi\Api4\Query\SqlExpression
;
7 use Civi\Api4\SavedSearch
;
8 use Civi\Api4\SearchDisplay
;
9 use Civi\Api4\Utils\CoreUtil
;
12 * Base class for running a search.
14 * @package Civi\Api4\Action\SearchDisplay
16 abstract class AbstractRunAction
extends \Civi\Api4\Generic\AbstractAction
{
19 * Either the name of the savedSearch or an array containing the savedSearch definition (for preview mode)
23 protected $savedSearch;
26 * Either the name of the display or an array containing the display definition (for preview mode)
33 * Array of fields to use for ordering the results
39 * Search conditions that will be automatically added to the WHERE or HAVING clauses
42 protected $filters = [];
45 * Integer used as a seed when ordering by RAND().
46 * This keeps the order stable enough to use a pager with random sorting.
53 * Name of Afform, if this display is embedded (used for permissioning)
59 * @var \Civi\Api4\Query\Api4SelectQuery
61 private $_selectQuery;
71 private $_selectClause;
74 * @param \Civi\Api4\Generic\Result $result
75 * @throws UnauthorizedException
76 * @throws \API_Exception
78 public function _run(\Civi\Api4\Generic\Result
$result) {
79 // Only administrators can use this in unsecured "preview mode"
80 if (!(is_string($this->savedSearch
) && is_string($this->display
)) && $this->checkPermissions
&& !\CRM_Core_Permission
::check('administer CiviCRM data')) {
81 throw new UnauthorizedException('Access denied');
83 if (is_string($this->savedSearch
)) {
84 $this->savedSearch
= SavedSearch
::get(FALSE)
85 ->addWhere('name', '=', $this->savedSearch
)
88 if (is_string($this->display
) && !empty($this->savedSearch
['id'])) {
89 $this->display
= SearchDisplay
::get(FALSE)
90 ->setSelect(['*', 'type:name'])
91 ->addWhere('name', '=', $this->display
)
92 ->addWhere('saved_search_id', '=', $this->savedSearch
['id'])
95 if (!$this->savedSearch ||
!$this->display
) {
96 throw new \
API_Exception("Error: SearchDisplay not found.");
98 // Displays with acl_bypass must be embedded on an afform which the user has access to
100 $this->checkPermissions
&& !empty($this->display
['acl_bypass']) &&
101 !\CRM_Core_Permission
::check('all CiviCRM permissions and ACLs') && !$this->loadAfform()
103 throw new UnauthorizedException('Access denied');
106 $this->savedSearch
['api_params'] +
= ['where' => []];
107 $this->savedSearch
['api_params']['checkPermissions'] = empty($this->display
['acl_bypass']);
108 $this->display
['settings']['columns'] = $this->display
['settings']['columns'] ??
[];
110 $this->processResult($result);
113 abstract protected function processResult(\Civi\Api4\Generic\Result
$result);
116 * Transforms each row into an array of raw data and an array of formatted columns
118 * @param \Civi\Api4\Generic\Result $result
119 * @return array{data: array, columns: array}[]
121 protected function formatResult(\Civi\Api4\Generic\Result
$result): array {
123 foreach ($result as $index => $row) {
124 $data = $columns = [];
125 foreach ($this->getSelectClause() as $key => $item) {
126 $data[$key] = $this->getValue($key, $row, $index);
128 foreach ($this->display
['settings']['columns'] as $column) {
129 $columns[] = $this->formatColumn($column, $data);
133 'columns' => $columns,
142 * @param int $rowIndex
145 private function getValue($key, $data, $rowIndex) {
146 // Get value from api result unless this is a pseudo-field which gets a calculated value
148 case 'result_row_num':
149 return $rowIndex +
1 +
($this->savedSearch
['api_params']['offset'] ??
0);
151 case 'user_contact_id':
152 return \CRM_Core_Session
::getLoggedInContactID();
155 return $data[$key] ??
NULL;
162 * @return array{val: mixed, links: array, edit: array, label: string, title: string, image: array, cssClass: string}
164 private function formatColumn($column, $data) {
165 $column +
= ['rewrite' => NULL, 'label' => NULL];
166 $out = $cssClass = [];
167 switch ($column['type']) {
169 if (isset($column['image']) && is_array($column['image'])) {
170 $out['img'] = $this->formatImage($column, $data);
171 $out['val'] = $this->replaceTokens($column['image']['alt'] ??
NULL, $data, 'view');
173 elseif ($column['rewrite']) {
174 $out['val'] = $this->replaceTokens($column['rewrite'], $data, 'view');
177 $out['val'] = $this->formatViewValue($column['key'], $data[$column['key']] ??
NULL);
179 if ($this->hasValue($column['label']) && (!empty($column['forceLabel']) ||
$this->hasValue($out['val']))) {
180 $out['label'] = $this->replaceTokens($column['label'], $data, 'view');
182 if (isset($column['title']) && strlen($column['title'])) {
183 $out['title'] = $this->replaceTokens($column['title'], $data, 'view');
185 if (!empty($column['link']['path'])) {
186 $out['links'] = $this->formatFieldLinks($column, $data, $out['val']);
188 elseif (!empty($column['editable']) && !$column['rewrite']) {
189 $out['edit'] = $this->formatEditableColumn($column, $data);
196 $out = $this->formatLinksColumn($column, $data);
199 if (!empty($column['alignment'])) {
200 $cssClass[] = $column['alignment'];
203 $out['cssClass'] = implode(' ', $cssClass);
209 * Format a field value as links
213 * @return array{text: string, url: string, target: string}[]
215 private function formatFieldLinks($column, $data, $value): array {
217 if (!empty($column['image'])) {
220 foreach ((array) $value as $index => $val) {
221 $path = $this->replaceTokens($column['link']['path'], $data, 'url', $index);
225 'url' => $this->getUrl($path),
227 if (!empty($column['link']['target'])) {
228 $link['target'] = $column['link']['target'];
237 * Format links for a menu/buttons/links column
240 * @return array{text: string, url: string, target: string, style: string, icon: string}[]
242 private function formatLinksColumn($column, $data): array {
243 $out = ['links' => []];
244 if (isset($column['text'])) {
245 $out['text'] = $this->replaceTokens($column['text'], $data, 'view');
247 foreach ($column['links'] as $item) {
248 $path = $this->replaceTokens($item['path'], $data, 'url');
251 'text' => $this->replaceTokens($item['text'] ??
'', $data, 'view'),
252 'url' => $this->getUrl($path),
254 foreach (['target', 'style', 'icon'] as $prop) {
255 if (!empty($item[$prop])) {
256 $link[$prop] = $item[$prop];
259 $out['links'][] = $link;
266 * @param string $path
269 private function getUrl(string $path) {
270 if ($path[0] === '/' ||
strpos($path, 'http://') ||
strpos($path, 'https://')) {
273 // Use absolute urls when downloading spreadsheet
274 $absolute = $this->getActionName() === 'download';
275 return \CRM_Utils_System
::url($path, NULL, $absolute, NULL, FALSE);
278 private function formatEditableColumn($column, $data) {
282 private function formatImage($column, $data) {
283 $tokenExpr = $column['rewrite'] ?
: '[' . $column['key'] . ']';
285 'url' => $this->replaceTokens($tokenExpr, $data, 'url'),
286 'height' => $column['image']['height'] ??
NULL,
287 'width' => $column['image']['width'] ??
NULL,
292 * Returns field definition for a given field or NULL if not found
296 protected function getField($fieldName) {
297 if (!$this->_selectQuery
) {
298 $api = \Civi\API\Request
::create($this->savedSearch
['api_entity'], 'get', $this->savedSearch
['api_params']);
299 $this->_selectQuery
= new \Civi\Api4\Query\
Api4SelectQuery($api);
301 return $this->_selectQuery
->getField($fieldName, FALSE);
305 * Returns the select clause enhanced with metadata
309 protected function getSelectClause() {
310 if (!isset($this->_selectClause
)) {
311 $this->_selectClause
= [];
312 foreach ($this->savedSearch
['api_params']['select'] as $selectExpr) {
313 $expr = SqlExpression
::convert($selectExpr, TRUE);
316 'type' => $expr->getType(),
317 'dataType' => $expr->getDataType(),
319 foreach ($expr->getFields() as $fieldName) {
320 $fieldMeta = $this->getField($fieldName);
322 $item['fields'][] = $fieldMeta;
325 if (!isset($item['dataType']) && $item['fields']) {
326 $item['dataType'] = $item['fields'][0]['data_type'];
328 $this->_selectClause
[$expr->getAlias()] = $item;
331 return $this->_selectClause
;
336 * @return array{fields: array, dataType: string}|NULL
338 protected function getSelectExpression($key) {
339 return $this->getSelectClause()[$key] ??
NULL;
343 * @param string $tokenExpr
345 * @param string $format view|raw|url
349 private function replaceTokens($tokenExpr, $data, $format, $index = 0) {
350 foreach ($this->getTokens($tokenExpr) as $token) {
351 $val = $data[$token] ??
NULL;
352 if (isset($val) && $format === 'view') {
353 $val = $this->formatViewValue($token, $val);
355 $replacement = is_array($val) ?
$val[$index] ??
'' : $val;
356 // A missing token value in a url invalidates it
357 if ($format === 'url' && (!isset($replacement) ||
$replacement === '')) {
360 $tokenExpr = str_replace('[' . $token . ']', $replacement, $tokenExpr);
366 * Format raw field value according to data type
368 * @param mixed $rawValue
369 * @return array|string
371 protected function formatViewValue($key, $rawValue) {
372 if (is_array($rawValue)) {
373 return array_map(function($val) use ($key) {
374 return $this->formatViewValue($key, $val);
378 $dataType = $this->getSelectExpression($key)['dataType'] ??
NULL;
380 $formatted = $rawValue;
384 if (is_bool($rawValue)) {
385 $formatted = $rawValue ?
ts('Yes') : ts('No');
390 $formatted = \CRM_Utils_Money
::format($rawValue);
395 $formatted = \CRM_Utils_Date
::customFormat($rawValue);
402 * Applies supplied filters to the where clause
404 protected function applyFilters() {
405 // Allow all filters that are included in SELECT clause or are fields on the Afform.
406 $allowedFilters = array_merge($this->getSelectAliases(), $this->getAfformFilters());
408 // Ignore empty strings
409 $filters = array_filter($this->filters
, [$this, 'hasValue']);
414 foreach ($filters as $key => $value) {
415 $fieldNames = explode(',', $key);
416 if (in_array($key, $allowedFilters, TRUE) ||
!array_diff($fieldNames, $allowedFilters)) {
417 $this->applyFilter($fieldNames, $value);
423 * Returns an array of field names or aliases + allowed suffixes from the SELECT clause
426 protected function getSelectAliases() {
428 $selectAliases = array_map(function($select) {
429 return array_slice(explode(' AS ', $select), -1)[0];
430 }, $this->savedSearch
['api_params']['select']);
431 foreach ($selectAliases as $alias) {
432 [$alias] = explode(':', $alias);
434 foreach (['name', 'label', 'abbr'] as $allowedSuffix) {
435 $result[] = $alias . ':' . $allowedSuffix;
442 * @param array $fieldNames
443 * If multiple field names are given they will be combined in an OR clause
444 * @param mixed $value
446 private function applyFilter(array $fieldNames, $value) {
447 // Global setting determines if % wildcard should be added to both sides (default) or only the end of a search string
448 $prefixWithWildcard = \Civi
::settings()->get('includeWildCardInName');
450 // Based on the first field, decide which clause to add this condition to
451 $fieldName = $fieldNames[0];
452 $field = $this->getField($fieldName);
453 // If field is not found it must be an aggregated column & belongs in the HAVING clause.
455 $this->savedSearch
['api_params']['having'] = $this->savedSearch
['api_params']['having'] ??
[];
456 $clause =& $this->savedSearch
['api_params']['having'];
458 // If field belongs to an EXCLUDE join, it should be added as a join condition
460 $prefix = strpos($fieldName, '.') ?
explode('.', $fieldName)[0] : NULL;
461 foreach ($this->savedSearch
['api_params']['join'] ??
[] as $idx => $join) {
462 if (($join[1] ??
'LEFT') === 'EXCLUDE' && (explode(' AS ', $join[0])[1] ??
'') === $prefix) {
463 $clause =& $this->savedSearch
['api_params']['join'][$idx];
467 // Default: add filter to WHERE clause
468 if (!isset($clause)) {
469 $clause =& $this->savedSearch
['api_params']['where'];
474 foreach ($fieldNames as $fieldName) {
475 $field = $this->getField($fieldName);
476 $dataType = $field['data_type'] ??
NULL;
477 // Array is either associative `OP => VAL` or sequential `IN (...)`
478 if (is_array($value)) {
479 $value = array_filter($value, [$this, 'hasValue']);
480 // If array does not contain operators as keys, assume array of values
481 if (array_diff_key($value, array_flip(CoreUtil
::getOperators()))) {
482 // Use IN for regular fields
483 if (empty($field['serialize'])) {
484 $filterClauses[] = [$fieldName, 'IN', $value];
486 // Use an OR group of CONTAINS for array fields
489 foreach ($value as $val) {
490 $orGroup[] = [$fieldName, 'CONTAINS', $val];
492 $filterClauses[] = ['OR', $orGroup];
495 // Operator => Value array
498 foreach ($value as $operator => $val) {
499 $andGroup[] = [$fieldName, $operator, $val];
501 $filterClauses[] = ['AND', $andGroup];
504 elseif (!empty($field['serialize'])) {
505 $filterClauses[] = [$fieldName, 'CONTAINS', $value];
507 elseif (!empty($field['options']) ||
in_array($dataType, ['Integer', 'Boolean', 'Date', 'Timestamp'])) {
508 $filterClauses[] = [$fieldName, '=', $value];
510 elseif ($prefixWithWildcard) {
511 $filterClauses[] = [$fieldName, 'CONTAINS', $value];
514 $filterClauses[] = [$fieldName, 'LIKE', $value . '%'];
518 if (count($filterClauses) === 1) {
519 $clause[] = $filterClauses[0];
522 $clause[] = ['OR', $filterClauses];
527 * Transforms the SORT param (which is expected to be an array of arrays)
528 * to the ORDER BY clause (which is an associative array of [field => DIR]
532 protected function getOrderByFromSort() {
533 $defaultSort = $this->display
['settings']['sort'] ??
[];
534 $currentSort = $this->sort
;
536 // Verify requested sort corresponds to sortable columns
537 foreach ($this->sort
as $item) {
538 $column = array_column($this->display
['settings']['columns'], NULL, 'key')[$item[0]] ??
NULL;
539 if (!$column ||
(isset($column['sortable']) && !$column['sortable'])) {
545 foreach ($currentSort ?
: $defaultSort as $item) {
546 // Apply seed to random sorting
547 if ($item[0] === 'RAND()' && isset($this->seed
)) {
548 $item[0] = 'RAND(' . $this->seed
. ')';
550 $orderBy[$item[0]] = $item[1];
556 * Adds additional fields to the select clause required to render the display
558 * @param array $apiParams
560 protected function augmentSelectClause(&$apiParams): void
{
561 $existing = array_map(function($item) {
562 return explode(' AS ', $item)[1] ??
$item;
563 }, $apiParams['select']);
565 // Add primary key field if actions are enabled
566 if (!empty($this->display
['settings']['actions'])) {
567 $additions = CoreUtil
::getInfoItem($this->savedSearch
['api_entity'], 'primary_key');
569 $possibleTokens = '';
570 foreach ($this->display
['settings']['columns'] as $column) {
571 // Collect display values in which a token is allowed
572 $possibleTokens .= ($column['rewrite'] ??
'') . ($column['link']['path'] ??
'');
573 if (!empty($column['links'])) {
574 $possibleTokens .= implode('', array_column($column['links'], 'path'));
575 $possibleTokens .= implode('', array_column($column['links'], 'text'));
578 // Select value fields for in-place editing
579 if (isset($column['editable']['value'])) {
580 $additions[] = $column['editable']['value'];
581 $additions[] = $column['editable']['id'];
584 // Add fields referenced via token
585 $tokens = $this->getTokens($possibleTokens);
586 // Only add fields not already in SELECT clause
587 $additions = array_diff(array_merge($additions, $tokens), $existing);
588 // Tokens for aggregated columns start with 'GROUP_CONCAT_'
589 foreach ($additions as $index => $alias) {
590 if (strpos($alias, 'GROUP_CONCAT_') === 0) {
591 $additions[$index] = 'GROUP_CONCAT(' . $this->getJoinFromAlias(explode('_', $alias, 3)[2]) . ') AS ' . $alias;
594 $this->_selectClause
= NULL;
595 $apiParams['select'] = array_unique(array_merge($apiParams['select'], $additions));
601 private function getTokens($str) {
603 preg_match_all('/\\[([^]]+)\\]/', $str, $tokens);
604 return array_unique($tokens[1]);
608 * Given an alias like Contact_Email_01_location_type_id
609 * this will return Contact_Email_01.location_type_id
610 * @param string $alias
613 protected function getJoinFromAlias(string $alias) {
615 foreach ($this->savedSearch
['api_params']['join'] ??
[] as $join) {
616 $joinName = explode(' AS ', $join[0])[1];
617 if (strpos($alias, $joinName) === 0) {
618 $parsed = $joinName . '.' . substr($alias, strlen($joinName) +
1);
619 // Ensure we are using the longest match
620 if (strlen($parsed) > strlen($result)) {
629 * Checks if a filter contains a non-empty value
631 * "Empty" search values are [], '', and NULL.
632 * Also recursively checks arrays to ensure they contain at least one non-empty value.
637 private function hasValue($value) {
638 return $value !== '' && $value !== NULL && (!is_array($value) ||
array_filter($value, [$this, 'hasValue']));
642 * Returns a list of filter fields and directive filters
644 * Automatically applies directive filters
648 private function getAfformFilters() {
649 $afform = $this->loadAfform();
653 // Get afform field filters
654 $filterKeys = array_column(\CRM_Utils_Array
::findAll(
655 $afform['layout'] ??
[],
656 ['#tag' => 'af-field']
658 // Get filters passed into search display directive from Afform markup
659 $filterAttr = $afform['searchDisplay']['filters'] ??
NULL;
660 if ($filterAttr && is_string($filterAttr) && $filterAttr[0] === '{') {
661 foreach (\CRM_Utils_JS
::decode($filterAttr) as $filterKey => $filterVal) {
662 // Automatically apply filters from the markup if they have a value
663 if ($filterVal !== NULL) {
664 unset($this->filters
[$filterKey]);
665 if ($this->hasValue($filterVal)) {
666 $this->applyFilter(explode(',', $filterKey), $filterVal);
669 // If it's a javascript variable it will have come back from decode() as NULL;
670 // whitelist it to allow it to be passed to this api from javascript.
672 $filterKeys[] = $filterKey;
680 * Return afform with name specified in api call.
682 * Verifies the searchDisplay is embedded in the afform and the user has permission to view it.
684 * @return array|false|null
686 private function loadAfform() {
687 // Only attempt to load afform once.
688 if ($this->afform
&& !isset($this->_afform
)) {
689 $this->_afform
= FALSE;
690 // Permission checks are enabled in this api call to ensure the user has permission to view the form
691 $afform = \Civi\Api4\Afform
::get()
692 ->addWhere('name', '=', $this->afform
)
693 ->setLayoutFormat('shallow')
694 ->execute()->first();
695 // Validate that the afform contains this search display
696 $afform['searchDisplay'] = \CRM_Utils_Array
::findAll(
697 $afform['layout'] ??
[],
698 ['#tag' => "{$this->display['type:name']}", 'display-name' => $this->display
['name']]
700 if ($afform['searchDisplay']) {
701 $this->_afform
= $afform;
704 return $this->_afform
;
708 * Extra calculated fields provided by SearchKit
711 public static function getPseudoFields(): array {
714 'name' => 'result_row_num',
715 'fieldName' => 'result_row_num',
716 'title' => ts('Row Number'),
717 'label' => ts('Row Number'),
718 'description' => ts('Index of each row, starting from 1 on the first page'),
720 'data_type' => 'Integer',
724 'name' => 'user_contact_id',
725 'fieldName' => 'result_row_num',
726 'title' => ts('Current User ID'),
727 'label' => ts('Current User ID'),
728 'description' => ts('Contact ID of the current user if logged in'),
730 'data_type' => 'Integer',