3 namespace Civi\Api4\Action\SearchDisplay
;
5 use Civi\API\Exception\UnauthorizedException
;
6 use Civi\Api4\Generic\Traits\ArrayQueryActionTrait
;
7 use Civi\Api4\SearchDisplay
;
8 use Civi\Api4\Utils\CoreUtil
;
11 * Base class for running a search.
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()
23 * @package Civi\Api4\Action\SearchDisplay
25 abstract class AbstractRunAction
extends \Civi\Api4\Generic\AbstractAction
{
27 use SavedSearchInspectorTrait
;
30 * Either the name of the display or an array containing the display definition (for preview mode)
32 * Leave NULL to use the autogenerated default.
34 * @var string|array|null
39 * Array of fields to use for ordering the results
45 * Search conditions that will be automatically added to the WHERE or HAVING clauses
48 protected $filters = [];
51 * Integer used as a seed when ordering by RAND().
52 * This keeps the order stable enough to use a pager with random sorting.
59 * Name of Afform, if this display is embedded (used for permissioning)
70 * @param \Civi\Api4\Generic\Result $result
71 * @throws UnauthorizedException
72 * @throws \API_Exception
74 public function _run(\Civi\Api4\Generic\Result
$result) {
75 // Only administrators can use this in unsecured "preview mode"
76 if ((is_array($this->savedSearch
) ||
is_array($this->display
)) && $this->checkPermissions
&& !\CRM_Core_Permission
::check('administer CiviCRM data')) {
77 throw new UnauthorizedException('Access denied');
79 $this->loadSavedSearch();
80 if (is_string($this->display
)) {
81 $this->display
= SearchDisplay
::get(FALSE)
82 ->setSelect(['*', 'type:name'])
83 ->addWhere('name', '=', $this->display
)
84 ->addWhere('saved_search_id', '=', $this->savedSearch
['id'])
85 ->execute()->single();
87 elseif (is_null($this->display
)) {
88 $this->display
= SearchDisplay
::getDefault(FALSE)
89 ->addSelect('*', 'type:name')
90 ->setSavedSearch($this->savedSearch
)
93 // Displays with acl_bypass must be embedded on an afform which the user has access to
95 $this->checkPermissions
&& !empty($this->display
['acl_bypass']) &&
96 !\CRM_Core_Permission
::check('all CiviCRM permissions and ACLs') && !$this->loadAfform()
98 throw new UnauthorizedException('Access denied');
101 $this->_apiParams
['checkPermissions'] = empty($this->display
['acl_bypass']);
102 $this->display
['settings']['columns'] = $this->display
['settings']['columns'] ??
[];
104 $this->processResult($result);
107 abstract protected function processResult(\Civi\Api4\Generic\Result
$result);
110 * Transforms each row into an array of raw data and an array of formatted columns
112 * @param \Civi\Api4\Generic\Result $result
113 * @return array{data: array, columns: array}[]
115 protected function formatResult(\Civi\Api4\Generic\Result
$result): array {
117 $keyName = CoreUtil
::getIdFieldName($this->savedSearch
['api_entity']);
118 foreach ($result as $index => $record) {
119 $data = $columns = [];
120 foreach ($this->getSelectClause() as $key => $item) {
121 $data[$key] = $this->getValue($key, $record, $index);
123 foreach ($this->display
['settings']['columns'] as $column) {
124 $columns[] = $this->formatColumn($column, $data);
126 $style = $this->getCssStyles($this->display
['settings']['cssRules'] ??
[], $data);
129 'columns' => $columns,
130 'cssClass' => implode(' ', $style),
132 if (isset($data[$keyName])) {
133 $row['key'] = $data[$keyName];
143 * @param int $rowIndex
146 private function getValue($key, $data, $rowIndex) {
147 // Get value from api result unless this is a pseudo-field which gets a calculated value
149 case 'result_row_num':
150 return $rowIndex +
1 +
($this->_apiParams
['offset'] ??
0);
152 case 'user_contact_id':
153 return \CRM_Core_Session
::getLoggedInContactID();
156 return $data[$key] ??
NULL;
163 * @return array{val: mixed, links: array, edit: array, label: string, title: string, image: array, cssClass: string}
165 private function formatColumn($column, $data) {
166 $column +
= ['rewrite' => NULL, 'label' => NULL];
168 switch ($column['type']) {
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');
174 elseif ($column['rewrite']) {
175 $out['val'] = $this->replaceTokens($column['rewrite'], $data, 'view');
178 $out['val'] = $this->formatViewValue($column['key'], $data[$column['key']] ??
NULL);
180 if ($this->hasValue($column['label']) && (!empty($column['forceLabel']) ||
$this->hasValue($out['val']))) {
181 $out['label'] = $this->replaceTokens($column['label'], $data, 'view');
183 if (isset($column['title']) && strlen($column['title'])) {
184 $out['title'] = $this->replaceTokens($column['title'], $data, 'view');
186 if (!empty($column['link'])) {
187 $links = $this->formatFieldLinks($column, $data, $out['val']);
189 $out['links'] = $links;
192 elseif (!empty($column['editable']) && !$column['rewrite']) {
193 $edit = $this->formatEditableColumn($column, $data);
195 $out['edit'] = $edit;
203 $out = $this->formatLinksColumn($column, $data);
206 $cssClass = $this->getCssStyles($column['cssRules'] ??
[], $data);
207 if (!empty($column['alignment'])) {
208 $cssClass[] = $column['alignment'];
211 $out['cssClass'] = implode(' ', $cssClass);
217 * Evaluates conditional style rules
219 * Rules are in the format ['css class', 'field_name', 'OPERATOR', 'value']
221 * @param array[] $styleRules
225 protected function getCssStyles(array $styleRules, array $data) {
227 foreach ($styleRules as $clause) {
228 $cssClass = $clause[0] ??
'';
230 $condition = $this->getCssRuleCondition($clause);
231 if (is_null($condition[0]) ||
(ArrayQueryActionTrait
::filterCompare($data, $condition))) {
232 $classes[] = $cssClass;
240 * Returns the condition of a cssRules
242 * @param array $clause
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]);
255 return [$fieldKey, $clause[2] ??
'IS NOT EMPTY', $clause[3] ??
NULL];
259 * Return fields needed for the select clause by a set of css rules
261 * @param array $cssRules
264 protected function getCssRulesSelect($cssRules) {
266 foreach ($cssRules as $clause) {
267 $fieldKey = $clause[1] ??
NULL;
269 // For fields used in group by, add aggregation
270 $select[] = $this->canAggregate($fieldKey) ?
"GROUP_CONCAT($fieldKey) AS GROUP_CONCAT_" . str_replace(['.', ':'], '_', $fieldKey) : $fieldKey;
277 * Format a field value as links
281 * @return array{text: string, url: string, target: string}[]
283 private function formatFieldLinks($column, $data, $value): array {
285 if (!empty($column['image'])) {
288 foreach ((array) $value as $index => $val) {
289 $path = $this->getLinkPath($column['link'], $data, $index);
290 $path = $this->replaceTokens($path, $data, 'url', $index);
294 'url' => $this->getUrl($path),
296 if (!empty($column['link']['target'])) {
297 $link['target'] = $column['link']['target'];
306 * Format links for a menu/buttons/links column
307 * @param array $column
309 * @return array{text: string, url: string, target: string, style: string, icon: string}[]
311 private function formatLinksColumn($column, $data): array {
312 $out = ['links' => []];
313 if (isset($column['text'])) {
314 $out['text'] = $this->replaceTokens($column['text'], $data, 'view');
316 foreach ($column['links'] as $item) {
317 $path = $this->replaceTokens($this->getLinkPath($item, $data), $data, 'url');
320 'text' => $this->replaceTokens($item['text'] ??
'', $data, 'view'),
321 'url' => $this->getUrl($path),
323 foreach (['target', 'style', 'icon'] as $prop) {
324 if (!empty($item[$prop])) {
325 $link[$prop] = $item[$prop];
328 $out['links'][] = $link;
338 * @return string|null
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';
350 $path = CoreUtil
::getInfoItem($entity, 'paths')[$link['action']] ??
NULL;
352 if ($path && !empty($link['join'])) {
353 $prefix = $link['join'] . '.';
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);
360 $path = str_replace('[', '[' . $prefix, $path);
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;
368 $access = civicrm_api4($link['entity'], 'checkAccess', [
369 'action' => $link['action'],
384 * @param string $path
387 private function getUrl(string $path) {
388 if ($path[0] === '/' ||
strpos($path, 'http://') ||
strpos($path, 'https://')) {
391 // Use absolute urls when downloading spreadsheet
392 $absolute = $this->getActionName() === 'download';
393 return \CRM_Utils_System
::url($path, NULL, $absolute, NULL, FALSE);
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
401 private function formatEditableColumn($column, $data) {
402 $editable = $this->getEditableInfo($column['key']);
403 if (!empty($data[$editable['id_path']])) {
404 $access = civicrm_api4($editable['entity'], 'checkAccess', [
405 'action' => 'update',
407 $editable['id_key'] => $data[$editable['id_path']],
413 $editable['record'] = [
414 $editable['id_key'] => $data[$editable['id_path']],
416 $editable['value'] = $data[$editable['value_path']];
417 \CRM_Utils_Array
::remove($editable, 'id_key', 'id_path', 'value_path');
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
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'])));
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';
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,
452 'id_path' => $idPath,
461 * @return array{url: string, width: int, height: int}
463 private function formatImage($column, $data) {
464 $tokenExpr = $column['rewrite'] ?
: '[' . $column['key'] . ']';
466 'src' => $this->replaceTokens($tokenExpr, $data, 'url'),
467 'height' => $column['image']['height'] ??
NULL,
468 'width' => $column['image']['width'] ??
NULL,
473 * @param string $tokenExpr
475 * @param string $format view|raw|url
479 private function replaceTokens($tokenExpr, $data, $format, $index = 0) {
481 foreach ($this->getTokens($tokenExpr) as $token) {
482 $val = $data[$token] ??
NULL;
483 if (isset($val) && $format === 'view') {
484 $val = $this->formatViewValue($token, $val);
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 === '')) {
491 $tokenExpr = str_replace('[' . $token . ']', $replacement, $tokenExpr);
498 * Format raw field value according to data type
500 * @param mixed $rawValue
501 * @return array|string
503 protected function formatViewValue($key, $rawValue) {
504 if (is_array($rawValue)) {
505 return array_map(function($val) use ($key) {
506 return $this->formatViewValue($key, $val);
510 $dataType = $this->getSelectExpression($key)['dataType'] ??
NULL;
512 $formatted = $rawValue;
516 if (is_bool($rawValue)) {
517 $formatted = $rawValue ?
ts('Yes') : ts('No');
522 $formatted = \CRM_Utils_Money
::format($rawValue);
527 $formatted = \CRM_Utils_Date
::customFormat($rawValue);
534 * Applies supplied filters to the where clause
536 protected function applyFilters() {
537 // Allow all filters that are included in SELECT clause or are fields on the Afform.
538 $allowedFilters = array_merge($this->getSelectAliases(), $this->getAfformFilters());
540 // Ignore empty strings
541 $filters = array_filter($this->filters
, [$this, 'hasValue']);
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);
555 * Returns an array of field names or aliases + allowed suffixes from the SELECT clause
558 protected function getSelectAliases() {
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);
566 foreach (['name', 'label', 'abbr'] as $allowedSuffix) {
567 $result[] = $alias . ':' . $allowedSuffix;
574 * @param array $fieldNames
575 * If multiple field names are given they will be combined in an OR clause
576 * @param mixed $value
578 private function applyFilter(array $fieldNames, $value) {
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');
582 // Based on the first field, decide which clause to add this condition to
583 $fieldName = $fieldNames[0];
584 $field = $this->getField($fieldName);
585 // If field is not found it must be an aggregated column & belongs in the HAVING clause.
587 $clause =& $this->_apiParams
['having'];
589 // If field belongs to an EXCLUDE join, it should be added as a join condition
591 $prefix = strpos($fieldName, '.') ?
explode('.', $fieldName)[0] : NULL;
592 foreach ($this->_apiParams
['join'] as $idx => $join) {
593 if (($join[1] ??
'LEFT') === 'EXCLUDE' && (explode(' AS ', $join[0])[1] ??
'') === $prefix) {
594 $clause =& $this->_apiParams
['join'][$idx];
598 // Default: add filter to WHERE clause
599 if (!isset($clause)) {
600 $clause =& $this->_apiParams
['where'];
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];
617 // Use an OR group of CONTAINS for array fields
620 foreach ($value as $val) {
621 $orGroup[] = [$fieldName, 'CONTAINS', $val];
623 $filterClauses[] = ['OR', $orGroup];
626 // Operator => Value array
629 foreach ($value as $operator => $val) {
630 $andGroup[] = [$fieldName, $operator, $val];
632 $filterClauses[] = ['AND', $andGroup];
635 elseif (!empty($field['serialize'])) {
636 $filterClauses[] = [$fieldName, 'CONTAINS', $value];
638 elseif (!empty($field['options']) ||
in_array($dataType, ['Integer', 'Boolean', 'Date', 'Timestamp'])) {
639 $filterClauses[] = [$fieldName, '=', $value];
641 elseif ($prefixWithWildcard) {
642 $filterClauses[] = [$fieldName, 'CONTAINS', $value];
645 $filterClauses[] = [$fieldName, 'LIKE', $value . '%'];
649 if (count($filterClauses) === 1) {
650 $clause[] = $filterClauses[0];
653 $clause[] = ['OR', $filterClauses];
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]
663 protected function getOrderByFromSort() {
664 $defaultSort = $this->display
['settings']['sort'] ??
[];
665 $currentSort = $this->sort
;
667 // Verify requested sort corresponds to sortable columns
668 foreach ($this->sort
as $item) {
669 $column = array_column($this->display
['settings']['columns'], NULL, 'key')[$item[0]] ??
NULL;
670 if (!$column ||
(isset($column['sortable']) && !$column['sortable'])) {
676 foreach ($currentSort ?
: $defaultSort as $item) {
677 // Apply seed to random sorting
678 if ($item[0] === 'RAND()' && isset($this->seed
)) {
679 $item[0] = 'RAND(' . $this->seed
. ')';
681 $orderBy[$item[0]] = $item[1];
687 * Adds additional fields to the select clause required to render the display
689 * @param array $apiParams
691 protected function augmentSelectClause(&$apiParams): void
{
692 $existing = array_map(function($item) {
693 return explode(' AS ', $item)[1] ??
$item;
694 }, $apiParams['select']);
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');
700 // Add style conditions for the display
701 foreach ($this->getCssRulesSelect($this->display
['settings']['cssRules'] ??
[]) as $addition) {
702 $additions[] = $addition;
704 $possibleTokens = '';
705 foreach ($this->display
['settings']['columns'] as $column) {
706 // Collect display values in which a token is allowed
707 $possibleTokens .= ($column['rewrite'] ??
'');
708 if (!empty($column['link'])) {
709 $possibleTokens .= $this->getLinkPath($column['link']) ??
'';
711 foreach ($column['links'] ??
[] as $link) {
712 $possibleTokens .= $link['text'] ??
'';
713 $possibleTokens .= $this->getLinkPath($link) ??
'';
716 // Select id & value for in-place editing
717 if (!empty($column['editable'])) {
718 $editable = $this->getEditableInfo($column['key']);
720 $additions[] = $editable['value_path'];
721 $additions[] = $editable['id_path'];
724 // Add style conditions for the column
725 foreach ($this->getCssRulesSelect($column['cssRules'] ??
[]) as $addition) {
726 $additions[] = $addition;
729 // Add fields referenced via token
730 $tokens = $this->getTokens($possibleTokens);
731 // Only add fields not already in SELECT clause
732 $additions = array_diff(array_merge($additions, $tokens), $existing);
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;
739 $this->_selectClause
= NULL;
740 $apiParams['select'] = array_unique(array_merge($apiParams['select'], $additions));
746 private function getTokens($str) {
748 preg_match_all('/\\[([^]]+)\\]/', $str, $tokens);
749 return array_unique($tokens[1]);
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
758 protected function getJoinFromAlias(string $alias) {
760 foreach ($this->_apiParams
['join'] as $join) {
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)) {
770 return $result ?
: $alias;
774 * Checks if a filter contains a non-empty value
776 * "Empty" search values are [], '', and NULL.
777 * Also recursively checks arrays to ensure they contain at least one non-empty value.
782 private function hasValue($value) {
783 return $value !== '' && $value !== NULL && (!is_array($value) ||
array_filter($value, [$this, 'hasValue']));
787 * Returns a list of filter fields and directive filters
789 * Automatically applies directive filters
793 private function getAfformFilters() {
794 $afform = $this->loadAfform();
798 // Get afform field filters
799 $filterKeys = array_column(\CRM_Utils_Array
::findAll(
800 $afform['layout'] ??
[],
801 ['#tag' => 'af-field']
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) {
807 // Automatically apply filters from the markup if they have a value
808 if ($filterVal !== NULL) {
809 unset($this->filters
[$filterKey]);
810 if ($this->hasValue($filterVal)) {
811 $this->applyFilter(explode(',', $filterKey), $filterVal);
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.
817 $filterKeys[] = $filterKey;
825 * Return afform with name specified in api call.
827 * Verifies the searchDisplay is embedded in the afform and the user has permission to view it.
829 * @return array|false|null
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']]
845 if ($afform['searchDisplay']) {
846 $this->_afform
= $afform;
849 return $this->_afform
;
853 * Extra calculated fields provided by SearchKit
856 public static function getPseudoFields(): array {
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'),
865 'data_type' => 'Integer',
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'),
875 'data_type' => 'Integer',