savedSearch) || is_array($this->display)) && $this->checkPermissions && !\CRM_Core_Permission::check('administer CiviCRM data')) { throw new UnauthorizedException('Access denied'); } $this->loadSavedSearch(); if (is_string($this->display)) { $this->display = SearchDisplay::get(FALSE) ->setSelect(['*', 'type:name']) ->addWhere('name', '=', $this->display) ->addWhere('saved_search_id', '=', $this->savedSearch['id']) ->execute()->single(); } elseif (is_null($this->display)) { $this->display = SearchDisplay::getDefault(FALSE) ->addSelect('*', 'type:name') ->setSavedSearch($this->savedSearch) ->execute()->first(); } // Displays with acl_bypass must be embedded on an afform which the user has access to if ( $this->checkPermissions && !empty($this->display['acl_bypass']) && !\CRM_Core_Permission::check('all CiviCRM permissions and ACLs') && !$this->loadAfform() ) { throw new UnauthorizedException('Access denied'); } $this->_apiParams['checkPermissions'] = empty($this->display['acl_bypass']); $this->display['settings']['columns'] = $this->display['settings']['columns'] ?? []; $this->processResult($result); } abstract protected function processResult(\Civi\Api4\Generic\Result $result); /** * Transforms each row into an array of raw data and an array of formatted columns * * @param \Civi\Api4\Generic\Result $result * @return array{data: array, columns: array}[] */ protected function formatResult(\Civi\Api4\Generic\Result $result): array { $rows = []; $keyName = CoreUtil::getIdFieldName($this->savedSearch['api_entity']); foreach ($result as $index => $record) { $data = $columns = []; foreach ($this->getSelectClause() as $key => $item) { $data[$key] = $this->getValue($key, $record, $index); } foreach ($this->display['settings']['columns'] as $column) { $columns[] = $this->formatColumn($column, $data); } $style = $this->getCssStyles($this->display['settings']['cssRules'] ?? [], $data); $row = [ 'data' => $data, 'columns' => $columns, 'cssClass' => implode(' ', $style), ]; if (isset($data[$keyName])) { $row['key'] = $data[$keyName]; } $rows[] = $row; } return $rows; } /** * @param string $key * @param array $data * @param int $rowIndex * @return mixed */ private function getValue($key, $data, $rowIndex) { // Get value from api result unless this is a pseudo-field which gets a calculated value switch ($key) { case 'result_row_num': return $rowIndex + 1 + ($this->_apiParams['offset'] ?? 0); case 'user_contact_id': return \CRM_Core_Session::getLoggedInContactID(); default: return $data[$key] ?? NULL; } } /** * @param $column * @param $data * @return array{val: mixed, links: array, edit: array, label: string, title: string, image: array, cssClass: string} */ private function formatColumn($column, $data) { $column += ['rewrite' => NULL, 'label' => NULL]; $out = []; switch ($column['type']) { case 'field': if (isset($column['image']) && is_array($column['image'])) { $out['img'] = $this->formatImage($column, $data); $out['val'] = $this->replaceTokens($column['image']['alt'] ?? NULL, $data, 'view'); } elseif ($column['rewrite']) { $out['val'] = $this->replaceTokens($column['rewrite'], $data, 'view'); } else { $out['val'] = $this->formatViewValue($column['key'], $data[$column['key']] ?? NULL); } if ($this->hasValue($column['label']) && (!empty($column['forceLabel']) || $this->hasValue($out['val']))) { $out['label'] = $this->replaceTokens($column['label'], $data, 'view'); } if (isset($column['title']) && strlen($column['title'])) { $out['title'] = $this->replaceTokens($column['title'], $data, 'view'); } if (!empty($column['link'])) { $links = $this->formatFieldLinks($column, $data, $out['val']); if ($links) { $out['links'] = $links; } } elseif (!empty($column['editable']) && !$column['rewrite']) { $edit = $this->formatEditableColumn($column, $data); if ($edit) { $out['edit'] = $edit; } } break; case 'links': case 'buttons': case 'menu': $out = $this->formatLinksColumn($column, $data); break; } $cssClass = $this->getCssStyles($column['cssRules'] ?? [], $data); if (!empty($column['alignment'])) { $cssClass[] = $column['alignment']; } if ($cssClass) { $out['cssClass'] = implode(' ', $cssClass); } return $out; } /** * Evaluates conditional style rules * * Rules are in the format ['css class', 'field_name', 'OPERATOR', 'value'] * * @param array[] $styleRules * @param array $data * @return array */ protected function getCssStyles(array $styleRules, array $data) { $classes = []; foreach ($styleRules as $clause) { $cssClass = $clause[0] ?? ''; if ($cssClass) { $condition = $this->getCssRuleCondition($clause); if (is_null($condition[0]) || (ArrayQueryActionTrait::filterCompare($data, $condition))) { $classes[] = $cssClass; } } } return $classes; } /** * Returns the condition of a cssRules * * @param array $clause * @return array */ protected function getCssRuleCondition($clause) { $fieldKey = $clause[1] ?? NULL; // For fields used in group by, add aggregation and change operator from = to CONTAINS // FIXME: This assumes the operator is always set to '=', which so far is all the admin UI supports. // That's only a safe assumption as long as the admin UI doesn't have an operator selector. // @see ang/crmSearchAdmin/displays/common/searchAdminCssRules.html if ($fieldKey && $this->canAggregate($fieldKey)) { $clause[2] = 'CONTAINS'; $fieldKey = 'GROUP_CONCAT_' . str_replace(['.', ':'], '_', $clause[1]); } return [$fieldKey, $clause[2] ?? 'IS NOT EMPTY', $clause[3] ?? NULL]; } /** * Return fields needed for the select clause by a set of css rules * * @param array $cssRules * @return array */ protected function getCssRulesSelect($cssRules) { $select = []; foreach ($cssRules as $clause) { $fieldKey = $clause[1] ?? NULL; if ($fieldKey) { // For fields used in group by, add aggregation $select[] = $this->canAggregate($fieldKey) ? "GROUP_CONCAT($fieldKey) AS GROUP_CONCAT_" . str_replace(['.', ':'], '_', $fieldKey) : $fieldKey; } } return $select; } /** * Format a field value as links * @param $column * @param $data * @param $value * @return array{text: string, url: string, target: string}[] */ private function formatFieldLinks($column, $data, $value): array { $links = []; if (!empty($column['image'])) { $value = ['']; } foreach ((array) $value as $index => $val) { $path = $this->getLinkPath($column['link'], $data, $index); $path = $this->replaceTokens($path, $data, 'url', $index); if ($path) { $link = [ 'text' => $val, 'url' => $this->getUrl($path), ]; if (!empty($column['link']['target'])) { $link['target'] = $column['link']['target']; } $links[] = $link; } } return $links; } /** * Format links for a menu/buttons/links column * @param array $column * @param array $data * @return array{text: string, url: string, target: string, style: string, icon: string}[] */ private function formatLinksColumn($column, $data): array { $out = ['links' => []]; if (isset($column['text'])) { $out['text'] = $this->replaceTokens($column['text'], $data, 'view'); } foreach ($column['links'] as $item) { $path = $this->replaceTokens($this->getLinkPath($item, $data), $data, 'url'); if ($path) { $link = [ 'text' => $this->replaceTokens($item['text'] ?? '', $data, 'view'), 'url' => $this->getUrl($path), ]; foreach (['target', 'style', 'icon'] as $prop) { if (!empty($item[$prop])) { $link[$prop] = $item[$prop]; } } $out['links'][] = $link; } } return $out; } /** * @param array $link * @param array $data * @param int $index * @return string|null */ private function getLinkPath($link, $data = NULL, $index = 0) { $path = $link['path'] ?? NULL; if (!$path && !empty($link['entity']) && !empty($link['action'])) { $entity = $link['entity']; $idField = $idKey = CoreUtil::getIdFieldName($entity); // Hack to support links to relationships if ($entity === 'Relationship') { $entity = 'RelationshipCache'; $idKey = 'relationship_id'; } $path = CoreUtil::getInfoItem($entity, 'paths')[$link['action']] ?? NULL; $prefix = ''; if ($path && !empty($link['join'])) { $prefix = $link['join'] . '.'; } // This is a bit clunky, the function_join_field gets un-munged later by $this->getJoinFromAlias() if ($this->canAggregate($prefix . $idKey)) { $prefix = 'GROUP_CONCAT_' . str_replace('.', '_', $prefix); } if ($prefix) { $path = str_replace('[', '[' . $prefix, $path); } // Check access for edit/update links // (presumably if a record is shown in SearchKit the user already has view access, and the check is expensive) if ($path && isset($data) && $link['action'] !== 'view') { $id = $data[$prefix . $idKey] ?? NULL; $id = is_array($id) ? $id[$index] ?? NULL : $id; if ($id) { $access = civicrm_api4($link['entity'], 'checkAccess', [ 'action' => $link['action'], 'values' => [ $idField => $id, ], ], 0)['access']; if (!$access) { return NULL; } } } } return $path; } /** * @param string $path * @return string */ private function getUrl(string $path) { if ($path[0] === '/' || strpos($path, 'http://') || strpos($path, 'https://')) { return $path; } // Use absolute urls when downloading spreadsheet $absolute = $this->getActionName() === 'download'; return \CRM_Utils_System::url($path, NULL, $absolute, NULL, FALSE); } /** * @param $column * @param $data * @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 */ private function formatEditableColumn($column, $data) { $editable = $this->getEditableInfo($column['key']); if (!empty($data[$editable['id_path']])) { $access = civicrm_api4($editable['entity'], 'checkAccess', [ 'action' => 'update', 'values' => [ $editable['id_key'] => $data[$editable['id_path']], ], ], 0)['access']; if (!$access) { return NULL; } $editable['record'] = [ $editable['id_key'] => $data[$editable['id_path']], ]; $editable['value'] = $data[$editable['value_path']]; \CRM_Utils_Array::remove($editable, 'id_key', 'id_path', 'value_path'); return $editable; } return NULL; } /** * @param $key * @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 */ private function getEditableInfo($key) { [$key] = explode(':', $key); $field = $this->getField($key); // If field is an implicit join, use the original fk field if (!empty($field['implicit_join'])) { return $this->getEditableInfo(substr($key, 0, -1 - strlen($field['name']))); } if ($field) { $idKey = CoreUtil::getIdFieldName($field['entity']); $idPath = ($field['explicit_join'] ? $field['explicit_join'] . '.' : '') . $idKey; // Hack to support editing relationships if ($field['entity'] === 'RelationshipCache') { $field['entity'] = 'Relationship'; $idPath = ($field['explicit_join'] ? $field['explicit_join'] . '.' : '') . 'relationship_id'; } return [ 'entity' => $field['entity'], 'input_type' => $field['input_type'], 'data_type' => $field['data_type'], 'options' => !empty($field['options']), 'serialize' => !empty($field['serialize']), 'fk_entity' => $field['fk_entity'], 'value_key' => $field['name'], 'value_path' => $key, 'id_key' => $idKey, 'id_path' => $idPath, ]; } return NULL; } /** * @param $column * @param $data * @return array{url: string, width: int, height: int} */ private function formatImage($column, $data) { $tokenExpr = $column['rewrite'] ?: '[' . $column['key'] . ']'; return [ 'src' => $this->replaceTokens($tokenExpr, $data, 'url'), 'height' => $column['image']['height'] ?? NULL, 'width' => $column['image']['width'] ?? NULL, ]; } /** * @param string $tokenExpr * @param array $data * @param string $format view|raw|url * @param int $index * @return string */ private function replaceTokens($tokenExpr, $data, $format, $index = 0) { if ($tokenExpr) { foreach ($this->getTokens($tokenExpr) as $token) { $val = $data[$token] ?? NULL; if (isset($val) && $format === 'view') { $val = $this->formatViewValue($token, $val); } $replacement = is_array($val) ? $val[$index] ?? '' : $val; // A missing token value in a url invalidates it if ($format === 'url' && (!isset($replacement) || $replacement === '')) { return NULL; } $tokenExpr = str_replace('[' . $token . ']', $replacement, $tokenExpr); } } return $tokenExpr; } /** * Format raw field value according to data type * @param string $key * @param mixed $rawValue * @return array|string */ protected function formatViewValue($key, $rawValue) { if (is_array($rawValue)) { return array_map(function($val) use ($key) { return $this->formatViewValue($key, $val); }, $rawValue); } $dataType = $this->getSelectExpression($key)['dataType'] ?? NULL; $formatted = $rawValue; switch ($dataType) { case 'Boolean': if (is_bool($rawValue)) { $formatted = $rawValue ? ts('Yes') : ts('No'); } break; case 'Money': $formatted = \CRM_Utils_Money::format($rawValue); break; case 'Date': case 'Timestamp': $formatted = \CRM_Utils_Date::customFormat($rawValue); } return $formatted; } /** * Applies supplied filters to the where clause */ protected function applyFilters() { // Allow all filters that are included in SELECT clause or are fields on the Afform. $allowedFilters = array_merge($this->getSelectAliases(), $this->getAfformFilters()); // Ignore empty strings $filters = array_filter($this->filters, [$this, 'hasValue']); if (!$filters) { return; } foreach ($filters as $key => $value) { $fieldNames = explode(',', $key); if (in_array($key, $allowedFilters, TRUE) || !array_diff($fieldNames, $allowedFilters)) { $this->applyFilter($fieldNames, $value); } } } /** * Returns an array of field names or aliases + allowed suffixes from the SELECT clause * @return string[] */ protected function getSelectAliases() { $result = []; $selectAliases = array_map(function($select) { return array_slice(explode(' AS ', $select), -1)[0]; }, $this->savedSearch['api_params']['select']); foreach ($selectAliases as $alias) { [$alias] = explode(':', $alias); $result[] = $alias; foreach (['name', 'label', 'abbr'] as $allowedSuffix) { $result[] = $alias . ':' . $allowedSuffix; } } return $result; } /** * @param array $fieldNames * If multiple field names are given they will be combined in an OR clause * @param mixed $value */ private function applyFilter(array $fieldNames, $value) { // Global setting determines if % wildcard should be added to both sides (default) or only the end of a search string $prefixWithWildcard = \Civi::settings()->get('includeWildCardInName'); // Based on the first field, decide which clause to add this condition to $fieldName = $fieldNames[0]; $field = $this->getField($fieldName); // If field is not found it must be an aggregated column & belongs in the HAVING clause. if (!$field) { $clause =& $this->_apiParams['having']; } // If field belongs to an EXCLUDE join, it should be added as a join condition else { $prefix = strpos($fieldName, '.') ? explode('.', $fieldName)[0] : NULL; foreach ($this->_apiParams['join'] as $idx => $join) { if (($join[1] ?? 'LEFT') === 'EXCLUDE' && (explode(' AS ', $join[0])[1] ?? '') === $prefix) { $clause =& $this->_apiParams['join'][$idx]; } } } // Default: add filter to WHERE clause if (!isset($clause)) { $clause =& $this->_apiParams['where']; } $filterClauses = []; foreach ($fieldNames as $fieldName) { $field = $this->getField($fieldName); $dataType = $field['data_type'] ?? NULL; // Array is either associative `OP => VAL` or sequential `IN (...)` if (is_array($value)) { $value = array_filter($value, [$this, 'hasValue']); // If array does not contain operators as keys, assume array of values if (array_diff_key($value, array_flip(CoreUtil::getOperators()))) { // Use IN for regular fields if (empty($field['serialize'])) { $filterClauses[] = [$fieldName, 'IN', $value]; } // Use an OR group of CONTAINS for array fields else { $orGroup = []; foreach ($value as $val) { $orGroup[] = [$fieldName, 'CONTAINS', $val]; } $filterClauses[] = ['OR', $orGroup]; } } // Operator => Value array else { $andGroup = []; foreach ($value as $operator => $val) { $andGroup[] = [$fieldName, $operator, $val]; } $filterClauses[] = ['AND', $andGroup]; } } elseif (!empty($field['serialize'])) { $filterClauses[] = [$fieldName, 'CONTAINS', $value]; } elseif (!empty($field['options']) || in_array($dataType, ['Integer', 'Boolean', 'Date', 'Timestamp'])) { $filterClauses[] = [$fieldName, '=', $value]; } elseif ($prefixWithWildcard) { $filterClauses[] = [$fieldName, 'CONTAINS', $value]; } else { $filterClauses[] = [$fieldName, 'LIKE', $value . '%']; } } // Single field if (count($filterClauses) === 1) { $clause[] = $filterClauses[0]; } else { $clause[] = ['OR', $filterClauses]; } } /** * Transforms the SORT param (which is expected to be an array of arrays) * to the ORDER BY clause (which is an associative array of [field => DIR] * * @return array */ protected function getOrderByFromSort() { $defaultSort = $this->display['settings']['sort'] ?? []; $currentSort = $this->sort; // Verify requested sort corresponds to sortable columns foreach ($this->sort as $item) { $column = array_column($this->display['settings']['columns'], NULL, 'key')[$item[0]] ?? NULL; if (!$column || (isset($column['sortable']) && !$column['sortable'])) { $currentSort = NULL; } } $orderBy = []; foreach ($currentSort ?: $defaultSort as $item) { // Apply seed to random sorting if ($item[0] === 'RAND()' && isset($this->seed)) { $item[0] = 'RAND(' . $this->seed . ')'; } $orderBy[$item[0]] = $item[1]; } return $orderBy; } /** * Adds additional fields to the select clause required to render the display * * @param array $apiParams */ protected function augmentSelectClause(&$apiParams): void { $existing = array_map(function($item) { return explode(' AS ', $item)[1] ?? $item; }, $apiParams['select']); $additions = []; // Add primary key field if actions are enabled if (!empty($this->display['settings']['actions'])) { $additions = CoreUtil::getInfoItem($this->savedSearch['api_entity'], 'primary_key'); } // Add style conditions for the display foreach ($this->getCssRulesSelect($this->display['settings']['cssRules'] ?? []) as $addition) { $additions[] = $addition; } $possibleTokens = ''; foreach ($this->display['settings']['columns'] as $column) { // Collect display values in which a token is allowed $possibleTokens .= ($column['rewrite'] ?? ''); if (!empty($column['link'])) { $possibleTokens .= $this->getLinkPath($column['link']) ?? ''; } foreach ($column['links'] ?? [] as $link) { $possibleTokens .= $link['text'] ?? ''; $possibleTokens .= $this->getLinkPath($link) ?? ''; } // Select id & value for in-place editing if (!empty($column['editable'])) { $editable = $this->getEditableInfo($column['key']); if ($editable) { $additions[] = $editable['value_path']; $additions[] = $editable['id_path']; } } // Add style conditions for the column foreach ($this->getCssRulesSelect($column['cssRules'] ?? []) as $addition) { $additions[] = $addition; } } // Add fields referenced via token $tokens = $this->getTokens($possibleTokens); // Only add fields not already in SELECT clause $additions = array_diff(array_merge($additions, $tokens), $existing); // Tokens for aggregated columns start with 'GROUP_CONCAT_' foreach ($additions as $index => $alias) { if (strpos($alias, 'GROUP_CONCAT_') === 0) { $additions[$index] = 'GROUP_CONCAT(' . $this->getJoinFromAlias(explode('_', $alias, 3)[2]) . ') AS ' . $alias; } } $this->_selectClause = NULL; $apiParams['select'] = array_unique(array_merge($apiParams['select'], $additions)); } /** * @param string $str */ private function getTokens($str) { $tokens = []; preg_match_all('/\\[([^]]+)\\]/', $str, $tokens); return array_unique($tokens[1]); } /** * Given an alias like Contact_Email_01_location_type_id * this will return Contact_Email_01.location_type_id * @param string $alias * @return string */ protected function getJoinFromAlias(string $alias) { $result = ''; foreach ($this->_apiParams['join'] as $join) { $joinName = explode(' AS ', $join[0])[1]; if (strpos($alias, $joinName) === 0) { $parsed = $joinName . '.' . substr($alias, strlen($joinName) + 1); // Ensure we are using the longest match if (strlen($parsed) > strlen($result)) { $result = $parsed; } } } return $result ?: $alias; } /** * Checks if a filter contains a non-empty value * * "Empty" search values are [], '', and NULL. * Also recursively checks arrays to ensure they contain at least one non-empty value. * * @param $value * @return bool */ private function hasValue($value) { return $value !== '' && $value !== NULL && (!is_array($value) || array_filter($value, [$this, 'hasValue'])); } /** * Returns a list of filter fields and directive filters * * Automatically applies directive filters * * @return array */ private function getAfformFilters() { $afform = $this->loadAfform(); if (!$afform) { return []; } // Get afform field filters $filterKeys = array_column(\CRM_Utils_Array::findAll( $afform['layout'] ?? [], ['#tag' => 'af-field'] ), 'name'); // Get filters passed into search display directive from Afform markup $filterAttr = $afform['searchDisplay']['filters'] ?? NULL; if ($filterAttr && is_string($filterAttr) && $filterAttr[0] === '{') { foreach (\CRM_Utils_JS::decode($filterAttr) as $filterKey => $filterVal) { // Automatically apply filters from the markup if they have a value if ($filterVal !== NULL) { unset($this->filters[$filterKey]); if ($this->hasValue($filterVal)) { $this->applyFilter(explode(',', $filterKey), $filterVal); } } // If it's a javascript variable it will have come back from decode() as NULL; // whitelist it to allow it to be passed to this api from javascript. else { $filterKeys[] = $filterKey; } } } return $filterKeys; } /** * Return afform with name specified in api call. * * Verifies the searchDisplay is embedded in the afform and the user has permission to view it. * * @return array|false|null */ private function loadAfform() { // Only attempt to load afform once. if ($this->afform && !isset($this->_afform)) { $this->_afform = FALSE; // Permission checks are enabled in this api call to ensure the user has permission to view the form $afform = \Civi\Api4\Afform::get() ->addWhere('name', '=', $this->afform) ->setLayoutFormat('shallow') ->execute()->first(); // Validate that the afform contains this search display $afform['searchDisplay'] = \CRM_Utils_Array::findAll( $afform['layout'] ?? [], ['#tag' => "{$this->display['type:name']}", 'display-name' => $this->display['name']] )[0] ?? NULL; if ($afform['searchDisplay']) { $this->_afform = $afform; } } return $this->_afform; } /** * Extra calculated fields provided by SearchKit * @return array[] */ public static function getPseudoFields(): array { return [ [ 'name' => 'result_row_num', 'fieldName' => 'result_row_num', 'title' => ts('Row Number'), 'label' => ts('Row Number'), 'description' => ts('Index of each row, starting from 1 on the first page'), 'type' => 'Pseudo', 'data_type' => 'Integer', 'readonly' => TRUE, ], [ 'name' => 'user_contact_id', 'fieldName' => 'result_row_num', 'title' => ts('Current User ID'), 'label' => ts('Current User ID'), 'description' => ts('Contact ID of the current user if logged in'), 'type' => 'Pseudo', 'data_type' => 'Integer', 'readonly' => TRUE, ], ]; } }