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