Merge pull request #22188 from totten/master-uninstall
[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;
cc7246dd 7use Civi\Api4\Query\SqlField;
e405006c
CW
8use Civi\Api4\SearchDisplay;
9use Civi\Api4\Utils\CoreUtil;
10
11/**
12 * Base class for running a search.
13 *
ea04af0c
CW
14 * @method $this setDisplay(array|string $display)
15 * @method array|string|null getDisplay()
16 * @method $this setSort(array $sort)
17 * @method array getSort()
18 * @method $this setFilters(array $filters)
19 * @method array getFilters()
20 * @method $this setSeed(string $seed)
21 * @method string getSeed()
22 * @method $this setAfform(string $afform)
23 * @method string getAfform()
e405006c
CW
24 * @package Civi\Api4\Action\SearchDisplay
25 */
26abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
27
ea04af0c 28 use SavedSearchInspectorTrait;
e405006c
CW
29
30 /**
31 * Either the name of the display or an array containing the display definition (for preview mode)
ea04af0c
CW
32 *
33 * Leave NULL to use the autogenerated default.
34 *
35 * @var string|array|null
e405006c
CW
36 */
37 protected $display;
38
39 /**
40 * Array of fields to use for ordering the results
41 * @var array
42 */
43 protected $sort = [];
44
45 /**
46 * Search conditions that will be automatically added to the WHERE or HAVING clauses
47 * @var array
48 */
49 protected $filters = [];
50
b53f1cad
CW
51 /**
52 * Integer used as a seed when ordering by RAND().
53 * This keeps the order stable enough to use a pager with random sorting.
54 *
55 * @var int
56 */
57 protected $seed;
58
e405006c
CW
59 /**
60 * Name of Afform, if this display is embedded (used for permissioning)
61 * @var string
62 */
63 protected $afform;
64
e405006c
CW
65 /**
66 * @var array
67 */
68 private $_afform;
69
70 /**
71 * @param \Civi\Api4\Generic\Result $result
72 * @throws UnauthorizedException
73 * @throws \API_Exception
74 */
75 public function _run(\Civi\Api4\Generic\Result $result) {
76 // Only administrators can use this in unsecured "preview mode"
ea04af0c 77 if ((is_array($this->savedSearch) || is_array($this->display)) && $this->checkPermissions && !\CRM_Core_Permission::check('administer CiviCRM data')) {
e405006c
CW
78 throw new UnauthorizedException('Access denied');
79 }
ea04af0c
CW
80 $this->loadSavedSearch();
81 if (is_string($this->display)) {
e405006c
CW
82 $this->display = SearchDisplay::get(FALSE)
83 ->setSelect(['*', 'type:name'])
84 ->addWhere('name', '=', $this->display)
85 ->addWhere('saved_search_id', '=', $this->savedSearch['id'])
ea04af0c 86 ->execute()->single();
e405006c 87 }
ea04af0c
CW
88 elseif (is_null($this->display)) {
89 $this->display = SearchDisplay::getDefault(FALSE)
5c952e51 90 ->addSelect('*', 'type:name')
ea04af0c
CW
91 ->setSavedSearch($this->savedSearch)
92 ->execute()->first();
e405006c
CW
93 }
94 // Displays with acl_bypass must be embedded on an afform which the user has access to
95 if (
96 $this->checkPermissions && !empty($this->display['acl_bypass']) &&
97 !\CRM_Core_Permission::check('all CiviCRM permissions and ACLs') && !$this->loadAfform()
98 ) {
99 throw new UnauthorizedException('Access denied');
100 }
101
7193d9f6 102 $this->_apiParams['checkPermissions'] = empty($this->display['acl_bypass']);
1fd2aa71 103 $this->display['settings']['columns'] = $this->display['settings']['columns'] ?? [];
e405006c
CW
104
105 $this->processResult($result);
106 }
107
108 abstract protected function processResult(\Civi\Api4\Generic\Result $result);
109
1fd2aa71 110 /**
8fd58f56
CW
111 * Transforms each row into an array of raw data and an array of formatted columns
112 *
1fd2aa71 113 * @param \Civi\Api4\Generic\Result $result
8fd58f56 114 * @return array{data: array, columns: array}[]
1fd2aa71
CW
115 */
116 protected function formatResult(\Civi\Api4\Generic\Result $result): array {
8fd58f56 117 $rows = [];
d4381c0f
CW
118 $keyName = CoreUtil::getIdFieldName($this->savedSearch['api_entity']);
119 foreach ($result as $index => $record) {
8fd58f56
CW
120 $data = $columns = [];
121 foreach ($this->getSelectClause() as $key => $item) {
d4381c0f 122 $data[$key] = $this->getValue($key, $record, $index);
1fd2aa71 123 }
8fd58f56
CW
124 foreach ($this->display['settings']['columns'] as $column) {
125 $columns[] = $this->formatColumn($column, $data);
1fd2aa71 126 }
f28a6f18 127 $style = $this->getCssStyles($this->display['settings']['cssRules'] ?? [], $data);
d4381c0f 128 $row = [
8fd58f56
CW
129 'data' => $data,
130 'columns' => $columns,
f28a6f18 131 'cssClass' => implode(' ', $style),
8fd58f56 132 ];
d4381c0f
CW
133 if (isset($data[$keyName])) {
134 $row['key'] = $data[$keyName];
135 }
136 $rows[] = $row;
1fd2aa71 137 }
8fd58f56 138 return $rows;
1fd2aa71
CW
139 }
140
7d527c18 141 /**
8fd58f56
CW
142 * @param string $key
143 * @param array $data
144 * @param int $rowIndex
145 * @return mixed
7d527c18 146 */
8fd58f56 147 private function getValue($key, $data, $rowIndex) {
7d527c18
CW
148 // Get value from api result unless this is a pseudo-field which gets a calculated value
149 switch ($key) {
150 case 'result_row_num':
7193d9f6 151 return $rowIndex + 1 + ($this->_apiParams['offset'] ?? 0);
7d527c18
CW
152
153 case 'user_contact_id':
8fd58f56 154 return \CRM_Core_Session::getLoggedInContactID();
7d527c18
CW
155
156 default:
cc7246dd
CW
157 if (!empty($data[$key])) {
158 $item = $this->getSelectExpression($key);
159 if ($item['expr'] instanceof SqlField && $item['fields'][0]['fk_entity'] === 'File') {
160 return $this->generateFileUrl($data[$key]);
161 }
162 }
8fd58f56
CW
163 return $data[$key] ?? NULL;
164 }
165 }
166
cc7246dd
CW
167 /**
168 * Convert file id to a readable url
169 *
170 * @param $fileID
171 * @return string
172 * @throws \CRM_Core_Exception
173 */
174 private function generateFileUrl($fileID) {
175 $entityId = \CRM_Core_DAO::getFieldValue('CRM_Core_DAO_EntityFile',
176 $fileID,
177 'entity_id',
178 'file_id'
179 );
180 $fileHash = \CRM_Core_BAO_File::generateFileHash($entityId, $fileID);
181 return $this->getUrl('civicrm/file', [
182 'reset' => 1,
183 'id' => $fileID,
184 'eid' => $entityId,
185 'fcs' => $fileHash,
186 ]);
187 }
188
8fd58f56
CW
189 /**
190 * @param $column
191 * @param $data
192 * @return array{val: mixed, links: array, edit: array, label: string, title: string, image: array, cssClass: string}
193 */
194 private function formatColumn($column, $data) {
195 $column += ['rewrite' => NULL, 'label' => NULL];
f28a6f18 196 $out = [];
8fd58f56
CW
197 switch ($column['type']) {
198 case 'field':
cc7246dd
CW
199 $rawValue = $data[$column['key']] ?? NULL;
200 if (!$this->hasValue($rawValue) && isset($column['empty_value'])) {
201 $out['val'] = $this->replaceTokens($column['empty_value'], $data, 'view');
8fd58f56
CW
202 }
203 elseif ($column['rewrite']) {
204 $out['val'] = $this->replaceTokens($column['rewrite'], $data, 'view');
205 }
206 else {
cc7246dd 207 $out['val'] = $this->formatViewValue($column['key'], $rawValue);
8fd58f56
CW
208 }
209 if ($this->hasValue($column['label']) && (!empty($column['forceLabel']) || $this->hasValue($out['val']))) {
210 $out['label'] = $this->replaceTokens($column['label'], $data, 'view');
211 }
ea04af0c
CW
212 if (!empty($column['link'])) {
213 $links = $this->formatFieldLinks($column, $data, $out['val']);
214 if ($links) {
215 $out['links'] = $links;
216 }
8fd58f56
CW
217 }
218 elseif (!empty($column['editable']) && !$column['rewrite']) {
ea04af0c
CW
219 $edit = $this->formatEditableColumn($column, $data);
220 if ($edit) {
221 $out['edit'] = $edit;
222 }
8fd58f56
CW
223 }
224 break;
225
cc7246dd
CW
226 case 'image':
227 $out['img'] = $this->formatImage($column, $data);
228 if ($out['img']) {
229 $out['val'] = $this->replaceTokens($column['image']['alt'] ?? NULL, $data, 'view');
230 }
231 if ($this->hasValue($column['label']) && (!empty($column['forceLabel']) || $out['img'])) {
232 $out['label'] = $this->replaceTokens($column['label'], $data, 'view');
233 }
234 if (!empty($column['link'])) {
235 $links = $this->formatFieldLinks($column, $data, '');
236 if ($links) {
237 $out['links'] = $links;
238 }
239 }
240 break;
241
8fd58f56
CW
242 case 'links':
243 case 'buttons':
244 case 'menu':
245 $out = $this->formatLinksColumn($column, $data);
246 break;
247 }
cc7246dd
CW
248 // Format tooltip
249 if (isset($column['title']) && strlen($column['title'])) {
250 $out['title'] = $this->replaceTokens($column['title'], $data, 'view');
251 }
f28a6f18 252 $cssClass = $this->getCssStyles($column['cssRules'] ?? [], $data);
8fd58f56
CW
253 if (!empty($column['alignment'])) {
254 $cssClass[] = $column['alignment'];
255 }
256 if ($cssClass) {
257 $out['cssClass'] = implode(' ', $cssClass);
258 }
259 return $out;
260 }
261
f28a6f18
CW
262 /**
263 * Evaluates conditional style rules
264 *
265 * Rules are in the format ['css class', 'field_name', 'OPERATOR', 'value']
266 *
267 * @param array[] $styleRules
268 * @param array $data
269 * @return array
270 */
271 protected function getCssStyles(array $styleRules, array $data) {
272 $classes = [];
273 foreach ($styleRules as $clause) {
274 $cssClass = $clause[0] ?? '';
275 if ($cssClass) {
276 $condition = $this->getCssRuleCondition($clause);
277 if (is_null($condition[0]) || (ArrayQueryActionTrait::filterCompare($data, $condition))) {
278 $classes[] = $cssClass;
279 }
280 }
281 }
282 return $classes;
283 }
284
285 /**
286 * Returns the condition of a cssRules
287 *
288 * @param array $clause
289 * @return array
290 */
291 protected function getCssRuleCondition($clause) {
292 $fieldKey = $clause[1] ?? NULL;
293 // For fields used in group by, add aggregation and change operator from = to CONTAINS
294 // FIXME: This assumes the operator is always set to '=', which so far is all the admin UI supports.
295 // That's only a safe assumption as long as the admin UI doesn't have an operator selector.
296 // @see ang/crmSearchAdmin/displays/common/searchAdminCssRules.html
297 if ($fieldKey && $this->canAggregate($fieldKey)) {
298 $clause[2] = 'CONTAINS';
299 $fieldKey = 'GROUP_CONCAT_' . str_replace(['.', ':'], '_', $clause[1]);
300 }
301 return [$fieldKey, $clause[2] ?? 'IS NOT EMPTY', $clause[3] ?? NULL];
302 }
303
304 /**
305 * Return fields needed for the select clause by a set of css rules
306 *
307 * @param array $cssRules
308 * @return array
309 */
310 protected function getCssRulesSelect($cssRules) {
311 $select = [];
312 foreach ($cssRules as $clause) {
313 $fieldKey = $clause[1] ?? NULL;
314 if ($fieldKey) {
315 // For fields used in group by, add aggregation
316 $select[] = $this->canAggregate($fieldKey) ? "GROUP_CONCAT($fieldKey) AS GROUP_CONCAT_" . str_replace(['.', ':'], '_', $fieldKey) : $fieldKey;
317 }
318 }
319 return $select;
320 }
321
8fd58f56
CW
322 /**
323 * Format a field value as links
324 * @param $column
325 * @param $data
326 * @param $value
327 * @return array{text: string, url: string, target: string}[]
328 */
329 private function formatFieldLinks($column, $data, $value): array {
330 $links = [];
8fd58f56 331 foreach ((array) $value as $index => $val) {
ea04af0c
CW
332 $path = $this->getLinkPath($column['link'], $data, $index);
333 $path = $this->replaceTokens($path, $data, 'url', $index);
8fd58f56
CW
334 if ($path) {
335 $link = [
336 'text' => $val,
337 'url' => $this->getUrl($path),
338 ];
339 if (!empty($column['link']['target'])) {
340 $link['target'] = $column['link']['target'];
341 }
342 $links[] = $link;
343 }
344 }
345 return $links;
346 }
347
348 /**
349 * Format links for a menu/buttons/links column
ea04af0c
CW
350 * @param array $column
351 * @param array $data
8fd58f56
CW
352 * @return array{text: string, url: string, target: string, style: string, icon: string}[]
353 */
354 private function formatLinksColumn($column, $data): array {
355 $out = ['links' => []];
356 if (isset($column['text'])) {
357 $out['text'] = $this->replaceTokens($column['text'], $data, 'view');
358 }
359 foreach ($column['links'] as $item) {
ea04af0c 360 $path = $this->replaceTokens($this->getLinkPath($item, $data), $data, 'url');
8fd58f56
CW
361 if ($path) {
362 $link = [
363 'text' => $this->replaceTokens($item['text'] ?? '', $data, 'view'),
364 'url' => $this->getUrl($path),
365 ];
366 foreach (['target', 'style', 'icon'] as $prop) {
367 if (!empty($item[$prop])) {
368 $link[$prop] = $item[$prop];
369 }
370 }
371 $out['links'][] = $link;
372 }
373 }
374 return $out;
375 }
376
ea04af0c
CW
377 /**
378 * @param array $link
379 * @param array $data
380 * @param int $index
381 * @return string|null
382 */
383 private function getLinkPath($link, $data = NULL, $index = 0) {
384 $path = $link['path'] ?? NULL;
385 if (!$path && !empty($link['entity']) && !empty($link['action'])) {
386 $entity = $link['entity'];
387 $idField = $idKey = CoreUtil::getIdFieldName($entity);
388 // Hack to support links to relationships
389 if ($entity === 'Relationship') {
390 $entity = 'RelationshipCache';
391 $idKey = 'relationship_id';
392 }
393 $path = CoreUtil::getInfoItem($entity, 'paths')[$link['action']] ?? NULL;
394 $prefix = '';
395 if ($path && !empty($link['join'])) {
396 $prefix = $link['join'] . '.';
397 }
398 // This is a bit clunky, the function_join_field gets un-munged later by $this->getJoinFromAlias()
399 if ($this->canAggregate($prefix . $idKey)) {
400 $prefix = 'GROUP_CONCAT_' . str_replace('.', '_', $prefix);
401 }
402 if ($prefix) {
403 $path = str_replace('[', '[' . $prefix, $path);
404 }
17d7be01
CW
405 // Check access for edit/update links
406 // (presumably if a record is shown in SearchKit the user already has view access, and the check is expensive)
407 if ($path && isset($data) && $link['action'] !== 'view') {
408 $id = $data[$prefix . $idKey] ?? NULL;
409 $id = is_array($id) ? $id[$index] ?? NULL : $id;
410 if ($id) {
411 $access = civicrm_api4($link['entity'], 'checkAccess', [
412 'action' => $link['action'],
413 'values' => [
414 $idField => $id,
415 ],
416 ], 0)['access'];
417 if (!$access) {
418 return NULL;
419 }
420 }
421 }
ea04af0c
CW
422 }
423 return $path;
424 }
425
8fd58f56
CW
426 /**
427 * @param string $path
cc7246dd 428 * @param array $query
8fd58f56
CW
429 * @return string
430 */
cc7246dd 431 private function getUrl(string $path, $query = NULL) {
8fd58f56
CW
432 if ($path[0] === '/' || strpos($path, 'http://') || strpos($path, 'https://')) {
433 return $path;
7d527c18 434 }
8fd58f56
CW
435 // Use absolute urls when downloading spreadsheet
436 $absolute = $this->getActionName() === 'download';
cc7246dd 437 return \CRM_Utils_System::url($path, $query, $absolute, NULL, FALSE);
8fd58f56
CW
438 }
439
c0fcc640
CW
440 /**
441 * @param $column
442 * @param $data
443 * @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
444 */
8fd58f56 445 private function formatEditableColumn($column, $data) {
c0fcc640
CW
446 $editable = $this->getEditableInfo($column['key']);
447 if (!empty($data[$editable['id_path']])) {
17d7be01
CW
448 $access = civicrm_api4($editable['entity'], 'checkAccess', [
449 'action' => 'update',
450 'values' => [
451 $editable['id_key'] => $data[$editable['id_path']],
452 ],
453 ], 0)['access'];
454 if (!$access) {
455 return NULL;
456 }
c0fcc640
CW
457 $editable['record'] = [
458 $editable['id_key'] => $data[$editable['id_path']],
459 ];
460 $editable['value'] = $data[$editable['value_path']];
461 \CRM_Utils_Array::remove($editable, 'id_key', 'id_path', 'value_path');
462 return $editable;
463 }
464 return NULL;
465 }
8fd58f56 466
c0fcc640
CW
467 /**
468 * @param $key
469 * @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
470 */
471 private function getEditableInfo($key) {
472 [$key] = explode(':', $key);
473 $field = $this->getField($key);
474 // If field is an implicit join, use the original fk field
475 if (!empty($field['implicit_join'])) {
476 return $this->getEditableInfo(substr($key, 0, -1 - strlen($field['name'])));
477 }
478 if ($field) {
479 $idKey = CoreUtil::getIdFieldName($field['entity']);
480 $idPath = ($field['explicit_join'] ? $field['explicit_join'] . '.' : '') . $idKey;
481 // Hack to support editing relationships
482 if ($field['entity'] === 'RelationshipCache') {
483 $field['entity'] = 'Relationship';
484 $idPath = ($field['explicit_join'] ? $field['explicit_join'] . '.' : '') . 'relationship_id';
485 }
486 return [
487 'entity' => $field['entity'],
488 'input_type' => $field['input_type'],
489 'data_type' => $field['data_type'],
490 'options' => !empty($field['options']),
491 'serialize' => !empty($field['serialize']),
492 'fk_entity' => $field['fk_entity'],
493 'value_key' => $field['name'],
494 'value_path' => $key,
495 'id_key' => $idKey,
496 'id_path' => $idPath,
497 ];
498 }
499 return NULL;
8fd58f56
CW
500 }
501
c0fcc640
CW
502 /**
503 * @param $column
504 * @param $data
cc7246dd 505 * @return array{url: string, width: int, height: int}|NULL
c0fcc640 506 */
8fd58f56
CW
507 private function formatImage($column, $data) {
508 $tokenExpr = $column['rewrite'] ?: '[' . $column['key'] . ']';
cc7246dd
CW
509 $url = $this->replaceTokens($tokenExpr, $data, 'url');
510 if (!$url && !empty($column['empty_value'])) {
511 $url = $this->replaceTokens($column['empty_value'], $data, 'url');
512 }
513 if (!$url) {
514 return NULL;
515 }
7d527c18 516 return [
cc7246dd 517 'src' => $url,
8fd58f56
CW
518 'height' => $column['image']['height'] ?? NULL,
519 'width' => $column['image']['width'] ?? NULL,
7d527c18
CW
520 ];
521 }
522
8fd58f56
CW
523 /**
524 * @param string $tokenExpr
525 * @param array $data
526 * @param string $format view|raw|url
527 * @param int $index
528 * @return string
529 */
530 private function replaceTokens($tokenExpr, $data, $format, $index = 0) {
ea04af0c
CW
531 if ($tokenExpr) {
532 foreach ($this->getTokens($tokenExpr) as $token) {
533 $val = $data[$token] ?? NULL;
534 if (isset($val) && $format === 'view') {
535 $val = $this->formatViewValue($token, $val);
536 }
537 $replacement = is_array($val) ? $val[$index] ?? '' : $val;
538 // A missing token value in a url invalidates it
539 if ($format === 'url' && (!isset($replacement) || $replacement === '')) {
540 return NULL;
541 }
542 $tokenExpr = str_replace('[' . $token . ']', $replacement, $tokenExpr);
8fd58f56 543 }
8fd58f56
CW
544 }
545 return $tokenExpr;
546 }
547
1fd2aa71
CW
548 /**
549 * Format raw field value according to data type
8fd58f56 550 * @param string $key
1fd2aa71
CW
551 * @param mixed $rawValue
552 * @return array|string
553 */
8fd58f56 554 protected function formatViewValue($key, $rawValue) {
1fd2aa71 555 if (is_array($rawValue)) {
8fd58f56
CW
556 return array_map(function($val) use ($key) {
557 return $this->formatViewValue($key, $val);
1fd2aa71
CW
558 }, $rawValue);
559 }
560
8fd58f56
CW
561 $dataType = $this->getSelectExpression($key)['dataType'] ?? NULL;
562
1fd2aa71
CW
563 $formatted = $rawValue;
564
565 switch ($dataType) {
566 case 'Boolean':
567 if (is_bool($rawValue)) {
568 $formatted = $rawValue ? ts('Yes') : ts('No');
569 }
570 break;
571
572 case 'Money':
573 $formatted = \CRM_Utils_Money::format($rawValue);
574 break;
575
576 case 'Date':
577 case 'Timestamp':
578 $formatted = \CRM_Utils_Date::customFormat($rawValue);
579 }
580
581 return $formatted;
582 }
583
e405006c
CW
584 /**
585 * Applies supplied filters to the where clause
586 */
587 protected function applyFilters() {
6cc91745
CW
588 // Allow all filters that are included in SELECT clause or are fields on the Afform.
589 $allowedFilters = array_merge($this->getSelectAliases(), $this->getAfformFilters());
590
e405006c
CW
591 // Ignore empty strings
592 $filters = array_filter($this->filters, [$this, 'hasValue']);
593 if (!$filters) {
594 return;
595 }
596
6cc91745
CW
597 foreach ($filters as $key => $value) {
598 $fieldNames = explode(',', $key);
599 if (in_array($key, $allowedFilters, TRUE) || !array_diff($fieldNames, $allowedFilters)) {
600 $this->applyFilter($fieldNames, $value);
e405006c
CW
601 }
602 }
603 }
604
605 /**
606 * Returns an array of field names or aliases + allowed suffixes from the SELECT clause
607 * @return string[]
608 */
609 protected function getSelectAliases() {
610 $result = [];
611 $selectAliases = array_map(function($select) {
612 return array_slice(explode(' AS ', $select), -1)[0];
613 }, $this->savedSearch['api_params']['select']);
614 foreach ($selectAliases as $alias) {
615 [$alias] = explode(':', $alias);
616 $result[] = $alias;
617 foreach (['name', 'label', 'abbr'] as $allowedSuffix) {
618 $result[] = $alias . ':' . $allowedSuffix;
619 }
620 }
621 return $result;
622 }
623
624 /**
6cc91745
CW
625 * @param array $fieldNames
626 * If multiple field names are given they will be combined in an OR clause
e405006c
CW
627 * @param mixed $value
628 */
6cc91745 629 private function applyFilter(array $fieldNames, $value) {
e405006c
CW
630 // Global setting determines if % wildcard should be added to both sides (default) or only the end of a search string
631 $prefixWithWildcard = \Civi::settings()->get('includeWildCardInName');
632
6cc91745
CW
633 // Based on the first field, decide which clause to add this condition to
634 $fieldName = $fieldNames[0];
e405006c
CW
635 $field = $this->getField($fieldName);
636 // If field is not found it must be an aggregated column & belongs in the HAVING clause.
637 if (!$field) {
7193d9f6 638 $clause =& $this->_apiParams['having'];
e405006c
CW
639 }
640 // If field belongs to an EXCLUDE join, it should be added as a join condition
641 else {
642 $prefix = strpos($fieldName, '.') ? explode('.', $fieldName)[0] : NULL;
7193d9f6 643 foreach ($this->_apiParams['join'] as $idx => $join) {
e405006c 644 if (($join[1] ?? 'LEFT') === 'EXCLUDE' && (explode(' AS ', $join[0])[1] ?? '') === $prefix) {
7193d9f6 645 $clause =& $this->_apiParams['join'][$idx];
e405006c
CW
646 }
647 }
648 }
649 // Default: add filter to WHERE clause
650 if (!isset($clause)) {
7193d9f6 651 $clause =& $this->_apiParams['where'];
e405006c
CW
652 }
653
6cc91745
CW
654 $filterClauses = [];
655
656 foreach ($fieldNames as $fieldName) {
657 $field = $this->getField($fieldName);
658 $dataType = $field['data_type'] ?? NULL;
659 // Array is either associative `OP => VAL` or sequential `IN (...)`
660 if (is_array($value)) {
661 $value = array_filter($value, [$this, 'hasValue']);
662 // If array does not contain operators as keys, assume array of values
663 if (array_diff_key($value, array_flip(CoreUtil::getOperators()))) {
664 // Use IN for regular fields
665 if (empty($field['serialize'])) {
666 $filterClauses[] = [$fieldName, 'IN', $value];
667 }
668 // Use an OR group of CONTAINS for array fields
669 else {
670 $orGroup = [];
671 foreach ($value as $val) {
672 $orGroup[] = [$fieldName, 'CONTAINS', $val];
673 }
674 $filterClauses[] = ['OR', $orGroup];
675 }
e405006c 676 }
6cc91745 677 // Operator => Value array
e405006c 678 else {
6cc91745
CW
679 $andGroup = [];
680 foreach ($value as $operator => $val) {
681 $andGroup[] = [$fieldName, $operator, $val];
e405006c 682 }
6cc91745 683 $filterClauses[] = ['AND', $andGroup];
e405006c
CW
684 }
685 }
6cc91745
CW
686 elseif (!empty($field['serialize'])) {
687 $filterClauses[] = [$fieldName, 'CONTAINS', $value];
688 }
689 elseif (!empty($field['options']) || in_array($dataType, ['Integer', 'Boolean', 'Date', 'Timestamp'])) {
690 $filterClauses[] = [$fieldName, '=', $value];
691 }
692 elseif ($prefixWithWildcard) {
693 $filterClauses[] = [$fieldName, 'CONTAINS', $value];
694 }
e405006c 695 else {
6cc91745 696 $filterClauses[] = [$fieldName, 'LIKE', $value . '%'];
e405006c
CW
697 }
698 }
6cc91745
CW
699 // Single field
700 if (count($filterClauses) === 1) {
701 $clause[] = $filterClauses[0];
e405006c
CW
702 }
703 else {
6cc91745 704 $clause[] = ['OR', $filterClauses];
e405006c
CW
705 }
706 }
707
708 /**
709 * Transforms the SORT param (which is expected to be an array of arrays)
710 * to the ORDER BY clause (which is an associative array of [field => DIR]
711 *
712 * @return array
713 */
714 protected function getOrderByFromSort() {
488560cc
CW
715 // Drag-sortable tables have a forced order
716 if (!empty($this->display['settings']['draggable'])) {
717 return [$this->display['settings']['draggable'] => 'ASC'];
718 }
719
e405006c
CW
720 $defaultSort = $this->display['settings']['sort'] ?? [];
721 $currentSort = $this->sort;
722
8fd58f56 723 // Verify requested sort corresponds to sortable columns
e405006c 724 foreach ($this->sort as $item) {
8fd58f56
CW
725 $column = array_column($this->display['settings']['columns'], NULL, 'key')[$item[0]] ?? NULL;
726 if (!$column || (isset($column['sortable']) && !$column['sortable'])) {
e405006c
CW
727 $currentSort = NULL;
728 }
729 }
730
731 $orderBy = [];
732 foreach ($currentSort ?: $defaultSort as $item) {
2eee3858
CW
733 // Apply seed to random sorting
734 if ($item[0] === 'RAND()' && isset($this->seed)) {
735 $item[0] = 'RAND(' . $this->seed . ')';
736 }
e405006c
CW
737 $orderBy[$item[0]] = $item[1];
738 }
739 return $orderBy;
740 }
741
742 /**
743 * Adds additional fields to the select clause required to render the display
744 *
745 * @param array $apiParams
746 */
747 protected function augmentSelectClause(&$apiParams): void {
4afe3e36
CW
748 $existing = array_map(function($item) {
749 return explode(' AS ', $item)[1] ?? $item;
750 }, $apiParams['select']);
c920297c
CW
751 $additions = [];
752 // Add primary key field if actions are enabled
488560cc 753 if (!empty($this->display['settings']['actions']) || !empty($this->display['settings']['draggable'])) {
c920297c
CW
754 $additions = CoreUtil::getInfoItem($this->savedSearch['api_entity'], 'primary_key');
755 }
488560cc
CW
756 // Add draggable column (typically "weight")
757 if (!empty($this->display['settings']['draggable'])) {
758 $additions[] = $this->display['settings']['draggable'];
759 }
f28a6f18
CW
760 // Add style conditions for the display
761 foreach ($this->getCssRulesSelect($this->display['settings']['cssRules'] ?? []) as $addition) {
762 $additions[] = $addition;
763 }
e405006c 764 $possibleTokens = '';
1fd2aa71 765 foreach ($this->display['settings']['columns'] as $column) {
e405006c 766 // Collect display values in which a token is allowed
ea04af0c 767 $possibleTokens .= ($column['rewrite'] ?? '');
cc7246dd
CW
768 $possibleTokens .= ($column['title'] ?? '');
769 $possibleTokens .= ($column['empty_value'] ?? '');
ea04af0c
CW
770 if (!empty($column['link'])) {
771 $possibleTokens .= $this->getLinkPath($column['link']) ?? '';
772 }
773 foreach ($column['links'] ?? [] as $link) {
774 $possibleTokens .= $link['text'] ?? '';
775 $possibleTokens .= $this->getLinkPath($link) ?? '';
e405006c
CW
776 }
777
c0fcc640
CW
778 // Select id & value for in-place editing
779 if (!empty($column['editable'])) {
780 $editable = $this->getEditableInfo($column['key']);
781 if ($editable) {
782 $additions[] = $editable['value_path'];
783 $additions[] = $editable['id_path'];
784 }
e405006c 785 }
f28a6f18
CW
786 // Add style conditions for the column
787 foreach ($this->getCssRulesSelect($column['cssRules'] ?? []) as $addition) {
788 $additions[] = $addition;
789 }
e405006c
CW
790 }
791 // Add fields referenced via token
8fd58f56 792 $tokens = $this->getTokens($possibleTokens);
4afe3e36 793 // Only add fields not already in SELECT clause
8fd58f56 794 $additions = array_diff(array_merge($additions, $tokens), $existing);
2fe33e6c
CW
795 // Tokens for aggregated columns start with 'GROUP_CONCAT_'
796 foreach ($additions as $index => $alias) {
797 if (strpos($alias, 'GROUP_CONCAT_') === 0) {
798 $additions[$index] = 'GROUP_CONCAT(' . $this->getJoinFromAlias(explode('_', $alias, 3)[2]) . ') AS ' . $alias;
799 }
800 }
8fd58f56 801 $this->_selectClause = NULL;
4afe3e36 802 $apiParams['select'] = array_unique(array_merge($apiParams['select'], $additions));
e405006c
CW
803 }
804
8fd58f56
CW
805 /**
806 * @param string $str
807 */
808 private function getTokens($str) {
809 $tokens = [];
810 preg_match_all('/\\[([^]]+)\\]/', $str, $tokens);
811 return array_unique($tokens[1]);
812 }
813
2fe33e6c
CW
814 /**
815 * Given an alias like Contact_Email_01_location_type_id
816 * this will return Contact_Email_01.location_type_id
817 * @param string $alias
818 * @return string
819 */
820 protected function getJoinFromAlias(string $alias) {
821 $result = '';
7193d9f6 822 foreach ($this->_apiParams['join'] as $join) {
2fe33e6c
CW
823 $joinName = explode(' AS ', $join[0])[1];
824 if (strpos($alias, $joinName) === 0) {
825 $parsed = $joinName . '.' . substr($alias, strlen($joinName) + 1);
826 // Ensure we are using the longest match
827 if (strlen($parsed) > strlen($result)) {
828 $result = $parsed;
829 }
830 }
831 }
f28a6f18 832 return $result ?: $alias;
2fe33e6c
CW
833 }
834
e405006c
CW
835 /**
836 * Checks if a filter contains a non-empty value
837 *
838 * "Empty" search values are [], '', and NULL.
839 * Also recursively checks arrays to ensure they contain at least one non-empty value.
840 *
841 * @param $value
842 * @return bool
843 */
844 private function hasValue($value) {
845 return $value !== '' && $value !== NULL && (!is_array($value) || array_filter($value, [$this, 'hasValue']));
846 }
847
848 /**
849 * Returns a list of filter fields and directive filters
850 *
851 * Automatically applies directive filters
852 *
853 * @return array
854 */
855 private function getAfformFilters() {
856 $afform = $this->loadAfform();
857 if (!$afform) {
858 return [];
859 }
860 // Get afform field filters
861 $filterKeys = array_column(\CRM_Utils_Array::findAll(
862 $afform['layout'] ?? [],
863 ['#tag' => 'af-field']
864 ), 'name');
865 // Get filters passed into search display directive from Afform markup
866 $filterAttr = $afform['searchDisplay']['filters'] ?? NULL;
867 if ($filterAttr && is_string($filterAttr) && $filterAttr[0] === '{') {
868 foreach (\CRM_Utils_JS::decode($filterAttr) as $filterKey => $filterVal) {
e405006c 869 // Automatically apply filters from the markup if they have a value
4d71b82e
CW
870 if ($filterVal !== NULL) {
871 unset($this->filters[$filterKey]);
872 if ($this->hasValue($filterVal)) {
873 $this->applyFilter(explode(',', $filterKey), $filterVal);
874 }
875 }
876 // If it's a javascript variable it will have come back from decode() as NULL;
877 // whitelist it to allow it to be passed to this api from javascript.
878 else {
879 $filterKeys[] = $filterKey;
e405006c
CW
880 }
881 }
882 }
883 return $filterKeys;
884 }
885
886 /**
887 * Return afform with name specified in api call.
888 *
889 * Verifies the searchDisplay is embedded in the afform and the user has permission to view it.
890 *
891 * @return array|false|null
892 */
893 private function loadAfform() {
894 // Only attempt to load afform once.
895 if ($this->afform && !isset($this->_afform)) {
896 $this->_afform = FALSE;
897 // Permission checks are enabled in this api call to ensure the user has permission to view the form
898 $afform = \Civi\Api4\Afform::get()
899 ->addWhere('name', '=', $this->afform)
900 ->setLayoutFormat('shallow')
901 ->execute()->first();
902 // Validate that the afform contains this search display
903 $afform['searchDisplay'] = \CRM_Utils_Array::findAll(
904 $afform['layout'] ?? [],
905 ['#tag' => "{$this->display['type:name']}", 'display-name' => $this->display['name']]
906 )[0] ?? NULL;
907 if ($afform['searchDisplay']) {
908 $this->_afform = $afform;
909 }
910 }
911 return $this->_afform;
912 }
913
7d527c18
CW
914 /**
915 * Extra calculated fields provided by SearchKit
916 * @return array[]
917 */
918 public static function getPseudoFields(): array {
919 return [
920 [
921 'name' => 'result_row_num',
922 'fieldName' => 'result_row_num',
923 'title' => ts('Row Number'),
924 'label' => ts('Row Number'),
925 'description' => ts('Index of each row, starting from 1 on the first page'),
926 'type' => 'Pseudo',
927 'data_type' => 'Integer',
928 'readonly' => TRUE,
929 ],
930 [
931 'name' => 'user_contact_id',
932 'fieldName' => 'result_row_num',
933 'title' => ts('Current User ID'),
934 'label' => ts('Current User ID'),
935 'description' => ts('Contact ID of the current user if logged in'),
936 'type' => 'Pseudo',
937 'data_type' => 'Integer',
938 'readonly' => TRUE,
939 ],
940 ];
941 }
942
e405006c 943}