a9e48d58c1129b4846ef91b3c3ab6e36df41706e
[civicrm-core.git] / ext / search_kit / Civi / Api4 / Action / SearchDisplay / AbstractRunAction.php
1 <?php
2
3 namespace Civi\Api4\Action\SearchDisplay;
4
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;
10
11 /**
12 * Base class for running a search.
13 *
14 * @package Civi\Api4\Action\SearchDisplay
15 */
16 abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
17
18 /**
19 * Either the name of the savedSearch or an array containing the savedSearch definition (for preview mode)
20 * @var string|array
21 * @required
22 */
23 protected $savedSearch;
24
25 /**
26 * Either the name of the display or an array containing the display definition (for preview mode)
27 * @var string|array
28 * @required
29 */
30 protected $display;
31
32 /**
33 * Array of fields to use for ordering the results
34 * @var array
35 */
36 protected $sort = [];
37
38 /**
39 * Search conditions that will be automatically added to the WHERE or HAVING clauses
40 * @var array
41 */
42 protected $filters = [];
43
44 /**
45 * Integer used as a seed when ordering by RAND().
46 * This keeps the order stable enough to use a pager with random sorting.
47 *
48 * @var int
49 */
50 protected $seed;
51
52 /**
53 * Name of Afform, if this display is embedded (used for permissioning)
54 * @var string
55 */
56 protected $afform;
57
58 /**
59 * @var \Civi\Api4\Query\Api4SelectQuery
60 */
61 private $_selectQuery;
62
63 /**
64 * @var array
65 */
66 private $_afform;
67
68 /**
69 * @var array
70 */
71 private $_selectClause;
72
73 /**
74 * @param \Civi\Api4\Generic\Result $result
75 * @throws UnauthorizedException
76 * @throws \API_Exception
77 */
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');
82 }
83 if (is_string($this->savedSearch)) {
84 $this->savedSearch = SavedSearch::get(FALSE)
85 ->addWhere('name', '=', $this->savedSearch)
86 ->execute()->first();
87 }
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'])
93 ->execute()->first();
94 }
95 if (!$this->savedSearch || !$this->display) {
96 throw new \API_Exception("Error: SearchDisplay not found.");
97 }
98 // Displays with acl_bypass must be embedded on an afform which the user has access to
99 if (
100 $this->checkPermissions && !empty($this->display['acl_bypass']) &&
101 !\CRM_Core_Permission::check('all CiviCRM permissions and ACLs') && !$this->loadAfform()
102 ) {
103 throw new UnauthorizedException('Access denied');
104 }
105
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'] ?? [];
109
110 $this->processResult($result);
111 }
112
113 abstract protected function processResult(\Civi\Api4\Generic\Result $result);
114
115 /**
116 * Transforms each row into an array of raw data and an array of formatted columns
117 *
118 * @param \Civi\Api4\Generic\Result $result
119 * @return array{data: array, columns: array}[]
120 */
121 protected function formatResult(\Civi\Api4\Generic\Result $result): array {
122 $rows = [];
123 foreach ($result as $index => $row) {
124 $data = $columns = [];
125 foreach ($this->getSelectClause() as $key => $item) {
126 $data[$key] = $this->getValue($key, $row, $index);
127 }
128 foreach ($this->display['settings']['columns'] as $column) {
129 $columns[] = $this->formatColumn($column, $data);
130 }
131 $rows[] = [
132 'data' => $data,
133 'columns' => $columns,
134 ];
135 }
136 return $rows;
137 }
138
139 /**
140 * @param string $key
141 * @param array $data
142 * @param int $rowIndex
143 * @return mixed
144 */
145 private function getValue($key, $data, $rowIndex) {
146 // Get value from api result unless this is a pseudo-field which gets a calculated value
147 switch ($key) {
148 case 'result_row_num':
149 return $rowIndex + 1 + ($this->savedSearch['api_params']['offset'] ?? 0);
150
151 case 'user_contact_id':
152 return \CRM_Core_Session::getLoggedInContactID();
153
154 default:
155 return $data[$key] ?? NULL;
156 }
157 }
158
159 /**
160 * @param $column
161 * @param $data
162 * @return array{val: mixed, links: array, edit: array, label: string, title: string, image: array, cssClass: string}
163 */
164 private function formatColumn($column, $data) {
165 $column += ['rewrite' => NULL, 'label' => NULL];
166 $out = $cssClass = [];
167 switch ($column['type']) {
168 case 'field':
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');
172 }
173 elseif ($column['rewrite']) {
174 $out['val'] = $this->replaceTokens($column['rewrite'], $data, 'view');
175 }
176 else {
177 $out['val'] = $this->formatViewValue($column['key'], $data[$column['key']] ?? NULL);
178 }
179 if ($this->hasValue($column['label']) && (!empty($column['forceLabel']) || $this->hasValue($out['val']))) {
180 $out['label'] = $this->replaceTokens($column['label'], $data, 'view');
181 }
182 if (isset($column['title']) && strlen($column['title'])) {
183 $out['title'] = $this->replaceTokens($column['title'], $data, 'view');
184 }
185 if (!empty($column['link']['path'])) {
186 $out['links'] = $this->formatFieldLinks($column, $data, $out['val']);
187 }
188 elseif (!empty($column['editable']) && !$column['rewrite']) {
189 $out['edit'] = $this->formatEditableColumn($column, $data);
190 }
191 break;
192
193 case 'links':
194 case 'buttons':
195 case 'menu':
196 $out = $this->formatLinksColumn($column, $data);
197 break;
198 }
199 if (!empty($column['alignment'])) {
200 $cssClass[] = $column['alignment'];
201 }
202 if ($cssClass) {
203 $out['cssClass'] = implode(' ', $cssClass);
204 }
205 return $out;
206 }
207
208 /**
209 * Format a field value as links
210 * @param $column
211 * @param $data
212 * @param $value
213 * @return array{text: string, url: string, target: string}[]
214 */
215 private function formatFieldLinks($column, $data, $value): array {
216 $links = [];
217 if (!empty($column['image'])) {
218 $value = [''];
219 }
220 foreach ((array) $value as $index => $val) {
221 $path = $this->replaceTokens($column['link']['path'], $data, 'url', $index);
222 if ($path) {
223 $link = [
224 'text' => $val,
225 'url' => $this->getUrl($path),
226 ];
227 if (!empty($column['link']['target'])) {
228 $link['target'] = $column['link']['target'];
229 }
230 $links[] = $link;
231 }
232 }
233 return $links;
234 }
235
236 /**
237 * Format links for a menu/buttons/links column
238 * @param $column
239 * @param $data
240 * @return array{text: string, url: string, target: string, style: string, icon: string}[]
241 */
242 private function formatLinksColumn($column, $data): array {
243 $out = ['links' => []];
244 if (isset($column['text'])) {
245 $out['text'] = $this->replaceTokens($column['text'], $data, 'view');
246 }
247 foreach ($column['links'] as $item) {
248 $path = $this->replaceTokens($item['path'], $data, 'url');
249 if ($path) {
250 $link = [
251 'text' => $this->replaceTokens($item['text'] ?? '', $data, 'view'),
252 'url' => $this->getUrl($path),
253 ];
254 foreach (['target', 'style', 'icon'] as $prop) {
255 if (!empty($item[$prop])) {
256 $link[$prop] = $item[$prop];
257 }
258 }
259 $out['links'][] = $link;
260 }
261 }
262 return $out;
263 }
264
265 /**
266 * @param string $path
267 * @return string
268 */
269 private function getUrl(string $path) {
270 if ($path[0] === '/' || strpos($path, 'http://') || strpos($path, 'https://')) {
271 return $path;
272 }
273 // Use absolute urls when downloading spreadsheet
274 $absolute = $this->getActionName() === 'download';
275 return \CRM_Utils_System::url($path, NULL, $absolute, NULL, FALSE);
276 }
277
278 private function formatEditableColumn($column, $data) {
279
280 }
281
282 private function formatImage($column, $data) {
283 $tokenExpr = $column['rewrite'] ?: '[' . $column['key'] . ']';
284 return [
285 'url' => $this->replaceTokens($tokenExpr, $data, 'url'),
286 'height' => $column['image']['height'] ?? NULL,
287 'width' => $column['image']['width'] ?? NULL,
288 ];
289 }
290
291 /**
292 * Returns field definition for a given field or NULL if not found
293 * @param $fieldName
294 * @return array|null
295 */
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);
300 }
301 return $this->_selectQuery->getField($fieldName, FALSE);
302 }
303
304 /**
305 * Returns the select clause enhanced with metadata
306 *
307 * @return array
308 */
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);
314 $item = [
315 'fields' => [],
316 'type' => $expr->getType(),
317 'dataType' => $expr->getDataType(),
318 ];
319 foreach ($expr->getFields() as $fieldName) {
320 $fieldMeta = $this->getField($fieldName);
321 if ($fieldMeta) {
322 $item['fields'][] = $fieldMeta;
323 }
324 }
325 if (!isset($item['dataType']) && $item['fields']) {
326 $item['dataType'] = $item['fields'][0]['data_type'];
327 }
328 $this->_selectClause[$expr->getAlias()] = $item;
329 }
330 }
331 return $this->_selectClause;
332 }
333
334 /**
335 * @param string $key
336 * @return array{fields: array, dataType: string}|NULL
337 */
338 protected function getSelectExpression($key) {
339 return $this->getSelectClause()[$key] ?? NULL;
340 }
341
342 /**
343 * @param string $tokenExpr
344 * @param array $data
345 * @param string $format view|raw|url
346 * @param int $index
347 * @return string
348 */
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);
354 }
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 === '')) {
358 return NULL;
359 }
360 $tokenExpr = str_replace('[' . $token . ']', $replacement, $tokenExpr);
361 }
362 return $tokenExpr;
363 }
364
365 /**
366 * Format raw field value according to data type
367 * @param string $key
368 * @param mixed $rawValue
369 * @return array|string
370 */
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);
375 }, $rawValue);
376 }
377
378 $dataType = $this->getSelectExpression($key)['dataType'] ?? NULL;
379
380 $formatted = $rawValue;
381
382 switch ($dataType) {
383 case 'Boolean':
384 if (is_bool($rawValue)) {
385 $formatted = $rawValue ? ts('Yes') : ts('No');
386 }
387 break;
388
389 case 'Money':
390 $formatted = \CRM_Utils_Money::format($rawValue);
391 break;
392
393 case 'Date':
394 case 'Timestamp':
395 $formatted = \CRM_Utils_Date::customFormat($rawValue);
396 }
397
398 return $formatted;
399 }
400
401 /**
402 * Applies supplied filters to the where clause
403 */
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());
407
408 // Ignore empty strings
409 $filters = array_filter($this->filters, [$this, 'hasValue']);
410 if (!$filters) {
411 return;
412 }
413
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);
418 }
419 }
420 }
421
422 /**
423 * Returns an array of field names or aliases + allowed suffixes from the SELECT clause
424 * @return string[]
425 */
426 protected function getSelectAliases() {
427 $result = [];
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);
433 $result[] = $alias;
434 foreach (['name', 'label', 'abbr'] as $allowedSuffix) {
435 $result[] = $alias . ':' . $allowedSuffix;
436 }
437 }
438 return $result;
439 }
440
441 /**
442 * @param array $fieldNames
443 * If multiple field names are given they will be combined in an OR clause
444 * @param mixed $value
445 */
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');
449
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.
454 if (!$field) {
455 $this->savedSearch['api_params']['having'] = $this->savedSearch['api_params']['having'] ?? [];
456 $clause =& $this->savedSearch['api_params']['having'];
457 }
458 // If field belongs to an EXCLUDE join, it should be added as a join condition
459 else {
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];
464 }
465 }
466 }
467 // Default: add filter to WHERE clause
468 if (!isset($clause)) {
469 $clause =& $this->savedSearch['api_params']['where'];
470 }
471
472 $filterClauses = [];
473
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];
485 }
486 // Use an OR group of CONTAINS for array fields
487 else {
488 $orGroup = [];
489 foreach ($value as $val) {
490 $orGroup[] = [$fieldName, 'CONTAINS', $val];
491 }
492 $filterClauses[] = ['OR', $orGroup];
493 }
494 }
495 // Operator => Value array
496 else {
497 $andGroup = [];
498 foreach ($value as $operator => $val) {
499 $andGroup[] = [$fieldName, $operator, $val];
500 }
501 $filterClauses[] = ['AND', $andGroup];
502 }
503 }
504 elseif (!empty($field['serialize'])) {
505 $filterClauses[] = [$fieldName, 'CONTAINS', $value];
506 }
507 elseif (!empty($field['options']) || in_array($dataType, ['Integer', 'Boolean', 'Date', 'Timestamp'])) {
508 $filterClauses[] = [$fieldName, '=', $value];
509 }
510 elseif ($prefixWithWildcard) {
511 $filterClauses[] = [$fieldName, 'CONTAINS', $value];
512 }
513 else {
514 $filterClauses[] = [$fieldName, 'LIKE', $value . '%'];
515 }
516 }
517 // Single field
518 if (count($filterClauses) === 1) {
519 $clause[] = $filterClauses[0];
520 }
521 else {
522 $clause[] = ['OR', $filterClauses];
523 }
524 }
525
526 /**
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]
529 *
530 * @return array
531 */
532 protected function getOrderByFromSort() {
533 $defaultSort = $this->display['settings']['sort'] ?? [];
534 $currentSort = $this->sort;
535
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'])) {
540 $currentSort = NULL;
541 }
542 }
543
544 $orderBy = [];
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 . ')';
549 }
550 $orderBy[$item[0]] = $item[1];
551 }
552 return $orderBy;
553 }
554
555 /**
556 * Adds additional fields to the select clause required to render the display
557 *
558 * @param array $apiParams
559 */
560 protected function augmentSelectClause(&$apiParams): void {
561 $existing = array_map(function($item) {
562 return explode(' AS ', $item)[1] ?? $item;
563 }, $apiParams['select']);
564 $additions = [];
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');
568 }
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'));
576 }
577
578 // Select value fields for in-place editing
579 if (isset($column['editable']['value'])) {
580 $additions[] = $column['editable']['value'];
581 $additions[] = $column['editable']['id'];
582 }
583 }
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;
592 }
593 }
594 $this->_selectClause = NULL;
595 $apiParams['select'] = array_unique(array_merge($apiParams['select'], $additions));
596 }
597
598 /**
599 * @param string $str
600 */
601 private function getTokens($str) {
602 $tokens = [];
603 preg_match_all('/\\[([^]]+)\\]/', $str, $tokens);
604 return array_unique($tokens[1]);
605 }
606
607 /**
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
611 * @return string
612 */
613 protected function getJoinFromAlias(string $alias) {
614 $result = '';
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)) {
621 $result = $parsed;
622 }
623 }
624 }
625 return $result;
626 }
627
628 /**
629 * Checks if a filter contains a non-empty value
630 *
631 * "Empty" search values are [], '', and NULL.
632 * Also recursively checks arrays to ensure they contain at least one non-empty value.
633 *
634 * @param $value
635 * @return bool
636 */
637 private function hasValue($value) {
638 return $value !== '' && $value !== NULL && (!is_array($value) || array_filter($value, [$this, 'hasValue']));
639 }
640
641 /**
642 * Returns a list of filter fields and directive filters
643 *
644 * Automatically applies directive filters
645 *
646 * @return array
647 */
648 private function getAfformFilters() {
649 $afform = $this->loadAfform();
650 if (!$afform) {
651 return [];
652 }
653 // Get afform field filters
654 $filterKeys = array_column(\CRM_Utils_Array::findAll(
655 $afform['layout'] ?? [],
656 ['#tag' => 'af-field']
657 ), 'name');
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);
667 }
668 }
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.
671 else {
672 $filterKeys[] = $filterKey;
673 }
674 }
675 }
676 return $filterKeys;
677 }
678
679 /**
680 * Return afform with name specified in api call.
681 *
682 * Verifies the searchDisplay is embedded in the afform and the user has permission to view it.
683 *
684 * @return array|false|null
685 */
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']]
699 )[0] ?? NULL;
700 if ($afform['searchDisplay']) {
701 $this->_afform = $afform;
702 }
703 }
704 return $this->_afform;
705 }
706
707 /**
708 * Extra calculated fields provided by SearchKit
709 * @return array[]
710 */
711 public static function getPseudoFields(): array {
712 return [
713 [
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'),
719 'type' => 'Pseudo',
720 'data_type' => 'Integer',
721 'readonly' => TRUE,
722 ],
723 [
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'),
729 'type' => 'Pseudo',
730 'data_type' => 'Integer',
731 'readonly' => TRUE,
732 ],
733 ];
734 }
735
736 }