Commit | Line | Data |
---|---|---|
e405006c CW |
1 | <?php |
2 | ||
3 | namespace Civi\Api4\Action\SearchDisplay; | |
4 | ||
5 | use Civi\API\Exception\UnauthorizedException; | |
f28a6f18 | 6 | use Civi\Api4\Generic\Traits\ArrayQueryActionTrait; |
cc7246dd | 7 | use Civi\Api4\Query\SqlField; |
e405006c CW |
8 | use Civi\Api4\SearchDisplay; |
9 | use 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 | */ | |
26 | abstract 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 | } |