APIv4 - Fix resolving pseudoconstants for less-permissioned users
[civicrm-core.git] / Civi / Api4 / Utils / FormattingUtil.php
CommitLineData
19b53e5b
C
1<?php
2
380f3545
TO
3/*
4 +--------------------------------------------------------------------+
41498ac5 5 | Copyright CiviCRM LLC. All rights reserved. |
380f3545 6 | |
41498ac5
TO
7 | This work is published under the GNU AGPLv3 license with some |
8 | permitted exceptions and without any warranty. For full license |
9 | and copyright information, see https://civicrm.org/licensing |
380f3545
TO
10 +--------------------------------------------------------------------+
11 */
12
19b53e5b
C
13namespace Civi\Api4\Utils;
14
7ce7b1cd
CW
15use Civi\Api4\Query\SqlExpression;
16
19b53e5b
C
17require_once 'api/v3/utils.php';
18
19class FormattingUtil {
20
18dd1d5f
CW
21 /**
22 * @var string[]
23 */
961e974c
CW
24 public static $pseudoConstantContexts = [
25 'name' => 'validate',
26 'abbr' => 'abbreviate',
27 'label' => 'get',
28 ];
29
18dd1d5f
CW
30 /**
31 * @var string[]
32 */
c100cf44 33 public static $pseudoConstantSuffixes = ['name', 'abbr', 'label', 'color', 'description', 'icon', 'grouping'];
3ffbd21c 34
19b53e5b
C
35 /**
36 * Massage values into the format the BAO expects for a write operation
37 *
37d82abe
CW
38 * @param array $params
39 * @param array $fields
19b53e5b
C
40 * @throws \API_Exception
41 */
37d82abe 42 public static function formatWriteParams(&$params, $fields) {
19b53e5b
C
43 foreach ($fields as $name => $field) {
44 if (!empty($params[$name])) {
45 $value =& $params[$name];
46 // Hack for null values -- see comment below
47 if ($value === 'null') {
48 $value = 'Null';
49 }
3dd9e4a0 50 self::formatInputValue($value, $name, $field);
19b53e5b
C
51 // Ensure we have an array for serialized fields
52 if (!empty($field['serialize'] && !is_array($value))) {
53 $value = (array) $value;
54 }
55 }
56 /*
57 * Because of the wacky way that database values are saved we need to format
58 * some of the values here. In this strange world the string 'null' is used to
236737d5
CW
59 * unset values. If we encounter true null at this layer we change it to an empty string
60 * and it will be converted to 'null' by CRM_Core_DAO::copyValues.
19b53e5b
C
61 *
62 * If we encounter the string 'null' then we assume the user actually wants to
63 * set the value to string null. However since the string null is reserved for
64 * unsetting values we must change it. Another quirk of the DB_DataObject is
65 * that it allows 'Null' to be set, but any other variation of string 'null'
66 * will be converted to true null, e.g. 'nuLL', 'NUlL' etc. so we change it to
67 * 'Null'.
68 */
69 elseif (array_key_exists($name, $params) && $params[$name] === NULL) {
236737d5 70 $params[$name] = '';
19b53e5b
C
71 }
72 }
c9b7a552
TO
73
74 \CRM_Utils_API_HTMLInputCoder::singleton()->encodeRow($params);
19b53e5b
C
75 }
76
77 /**
78 * Transform raw api input to appropriate format for use in a SQL query.
79 *
80 * This is used by read AND write actions (Get, Create, Update, Replace)
81 *
82 * @param $value
10ab77cb 83 * @param string|null $fieldName
961e974c 84 * @param array $fieldSpec
10ab77cb
CW
85 * @param string|null $operator (only for 'get' actions)
86 * @param null $index (for recursive loops)
19b53e5b 87 * @throws \API_Exception
3ffbd21c 88 * @throws \CRM_Core_Exception
19b53e5b 89 */
10ab77cb 90 public static function formatInputValue(&$value, ?string $fieldName, array $fieldSpec, &$operator = NULL, $index = NULL) {
961e974c 91 // Evaluate pseudoconstant suffix
16fd31ae 92 $suffix = strpos(($fieldName ?? ''), ':');
961e974c 93 if ($suffix) {
265192b2 94 $options = self::getPseudoconstantList($fieldSpec, $fieldName, [], $operator ? 'get' : 'create');
961e974c 95 $value = self::replacePseudoconstant($options, $value, TRUE);
16f5a13d 96 return;
961e974c
CW
97 }
98 elseif (is_array($value)) {
3dd9e4a0 99 $i = 0;
19b53e5b 100 foreach ($value as &$val) {
3dd9e4a0 101 self::formatInputValue($val, $fieldName, $fieldSpec, $operator, $i++);
19b53e5b
C
102 }
103 return;
104 }
37d82abe 105 $fk = $fieldSpec['name'] == 'id' ? $fieldSpec['entity'] : $fieldSpec['fk_entity'] ?? NULL;
19b53e5b
C
106
107 if ($fk === 'Domain' && $value === 'current_domain') {
108 $value = \CRM_Core_Config::domainID();
109 }
110
111 if ($fk === 'Contact' && !is_numeric($value)) {
112 $value = \_civicrm_api3_resolve_contactID($value);
113 if ('unknown-user' === $value) {
114 throw new \API_Exception("\"{$fieldSpec['name']}\" \"{$value}\" cannot be resolved to a contact ID", 2002, ['error_field' => $fieldSpec['name'], "type" => "integer"]);
115 }
116 }
117
2929a8fb 118 switch ($fieldSpec['data_type'] ?? NULL) {
19b53e5b 119 case 'Timestamp':
98e424ab 120 $value = self::formatDateValue('YmdHis', $value, $operator, $index);
19b53e5b
C
121 break;
122
123 case 'Date':
3dd9e4a0 124 $value = self::formatDateValue('Ymd', $value, $operator, $index);
19b53e5b
C
125 break;
126 }
c9b7a552
TO
127
128 $hic = \CRM_Utils_API_HTMLInputCoder::singleton();
10ab77cb 129 if (is_string($value) && $fieldName && !$hic->isSkippedField($fieldSpec['name'])) {
c9b7a552
TO
130 $value = $hic->encodeValue($value);
131 }
19b53e5b
C
132 }
133
3dd9e4a0
CW
134 /**
135 * Parse date expressions.
136 *
137 * Expands relative date range expressions, modifying the sql operator if necessary
138 *
139 * @param $format
140 * @param $value
141 * @param $operator
142 * @param $index
143 * @return array|string
144 */
10ab77cb 145 public static function formatDateValue($format, $value, &$operator = NULL, $index = NULL) {
3dd9e4a0
CW
146 // Non-relative dates (or if no search operator)
147 if (!$operator || !array_key_exists($value, \CRM_Core_OptionGroup::values('relative_date_filters'))) {
16fd31ae 148 return date($format, strtotime($value ?? ''));
3dd9e4a0
CW
149 }
150 if (isset($index) && !strstr($operator, 'BETWEEN')) {
151 throw new \API_Exception("Relative dates cannot be in an array using the $operator operator.");
152 }
153 [$dateFrom, $dateTo] = \CRM_Utils_Date::getFromTo($value);
154 switch ($operator) {
155 // Convert relative date filters to use BETWEEN/NOT BETWEEN operator
156 case '=':
157 case '!=':
158 case '<>':
159 case 'LIKE':
160 case 'NOT LIKE':
161 $operator = ($operator === '=' || $operator === 'LIKE') ? 'BETWEEN' : 'NOT BETWEEN';
162 return [self::formatDateValue($format, $dateFrom), self::formatDateValue($format, $dateTo)];
163
164 // Less-than or greater-than-equal-to comparisons use the lower value
165 case '<':
166 case '>=':
167 return self::formatDateValue($format, $dateFrom);
168
169 // Greater-than or less-than-equal-to comparisons use the higher value
170 case '>':
171 case '<=':
172 return self::formatDateValue($format, $dateTo);
173
174 // For BETWEEN expressions, we are already inside a loop of the 2 values, so give the lower value if index=0, higher value if index=1
175 case 'BETWEEN':
176 case 'NOT BETWEEN':
177 return self::formatDateValue($format, $index ? $dateTo : $dateFrom);
178
179 default:
180 throw new \API_Exception("Relative dates cannot be used with the $operator operator.");
181 }
182 }
183
2929a8fb
CW
184 /**
185 * Unserialize raw DAO values and convert to correct type
186 *
187 * @param array $results
188 * @param array $fields
961e974c 189 * @param string $action
7ce7b1cd 190 * @param array $selectAliases
961e974c 191 * @throws \API_Exception
2929a8fb
CW
192 * @throws \CRM_Core_Exception
193 */
afb75a23 194 public static function formatOutputValues(&$results, $fields, $action = 'get', $selectAliases = []) {
2929a8fb 195 foreach ($results as &$result) {
d818aa7b 196 $contactTypePaths = [];
7ce7b1cd
CW
197 foreach ($result as $key => $value) {
198 $fieldExpr = SqlExpression::convert($selectAliases[$key] ?? $key);
16fd31ae 199 $fieldName = \CRM_Utils_Array::first($fieldExpr->getFields() ?? '');
afb75a23
CW
200 $baseName = $fieldName ? \CRM_Utils_Array::first(explode(':', $fieldName)) : NULL;
201 $field = $fields[$fieldName] ?? $fields[$baseName] ?? NULL;
7ce7b1cd 202 $dataType = $field['data_type'] ?? ($fieldName == 'id' ? 'Integer' : NULL);
b0aa3463 203 // Allow Sql Functions to do special formatting and/or alter the $dataType
7ce7b1cd 204 if (method_exists($fieldExpr, 'formatOutputValue') && is_string($value)) {
259207d0 205 $result[$key] = $value = $fieldExpr->formatOutputValue($value, $dataType);
7ce7b1cd 206 }
96f09dda
CW
207 if (!empty($field['output_formatters'])) {
208 self::applyFormatters($result, $fieldName, $field, $value);
209 $dataType = NULL;
210 }
7ce7b1cd 211 // Evaluate pseudoconstant suffixes
16fd31ae 212 $suffix = strrpos(($fieldName ?? ''), ':');
3d2f86c5 213 $fieldOptions = NULL;
7ce7b1cd 214 if ($suffix) {
3d2f86c5 215 $fieldOptions = self::getPseudoconstantList($field, $fieldName, $result, $action);
7ce7b1cd
CW
216 $dataType = NULL;
217 }
218 if ($fieldExpr->supportsExpansion) {
219 if (!empty($field['serialize']) && is_string($value)) {
220 $value = \CRM_Core_DAO::unSerializeField($value, $field['serialize']);
961e974c 221 }
3d2f86c5
CW
222 if (isset($fieldOptions)) {
223 $value = self::replacePseudoconstant($fieldOptions, $value);
d818aa7b 224 }
2929a8fb 225 }
7ce7b1cd
CW
226 // Keep track of contact types for self::contactFieldsToRemove
227 if ($value && isset($field['entity']) && $field['entity'] === 'Contact' && $field['name'] === 'contact_type') {
228 $prefix = strrpos($fieldName, '.');
229 $contactTypePaths[$prefix ? substr($fieldName, 0, $prefix + 1) : ''] = $value;
230 }
231 $result[$key] = self::convertDataType($value, $dataType);
961e974c 232 }
d818aa7b
CW
233 // Remove inapplicable contact fields
234 foreach ($contactTypePaths as $prefix => $contactType) {
235 \CRM_Utils_Array::remove($result, self::contactFieldsToRemove($contactType, $prefix));
236 }
961e974c
CW
237 }
238 }
239
240 /**
241 * Retrieves pseudoconstant option list for a field.
242 *
721c9da1 243 * @param array $field
265192b2
CW
244 * @param string $fieldAlias
245 * Field path plus pseudoconstant suffix, e.g. 'contact.employer_id.contact_sub_type:label'
961e974c
CW
246 * @param array $params
247 * Other values for this object
248 * @param string $action
249 * @return array
250 * @throws \API_Exception
251 */
265192b2
CW
252 public static function getPseudoconstantList(array $field, string $fieldAlias, $params = [], $action = 'get') {
253 [$fieldPath, $valueType] = explode(':', $fieldAlias);
bb6bfd68 254 $context = self::$pseudoConstantContexts[$valueType] ?? NULL;
3ffbd21c
CW
255 // For create actions, only unique identifiers can be used.
256 // For get actions any valid suffix is ok.
257 if (($action === 'create' && !$context) || !in_array($valueType, self::$pseudoConstantSuffixes, TRUE)) {
961e974c
CW
258 throw new \API_Exception('Illegal expression');
259 }
721c9da1 260 $baoName = $context ? CoreUtil::getBAOFromApiName($field['entity']) : NULL;
961e974c
CW
261 // Use BAO::buildOptions if possible
262 if ($baoName) {
721c9da1 263 $fieldName = empty($field['custom_field_id']) ? $field['name'] : 'custom_' . $field['custom_field_id'];
265192b2 264 $options = $baoName::buildOptions($fieldName, $context, self::filterByPrefix($params, $fieldPath, $field['name']));
961e974c 265 }
3ffbd21c 266 // Fallback for option lists that exist in the api but not the BAO
bb6bfd68 267 if (!isset($options) || $options === FALSE) {
05d4c0d2 268 $options = civicrm_api4($field['entity'], 'getFields', ['checkPermissions' => FALSE, 'action' => $action, 'loadOptions' => ['id', $valueType], 'where' => [['name', '=', $field['name']]]])[0]['options'] ?? NULL;
3ffbd21c 269 $options = $options ? array_column($options, $valueType, 'id') : $options;
961e974c
CW
270 }
271 if (is_array($options)) {
272 return $options;
273 }
721c9da1 274 throw new \API_Exception("No option list found for '{$field['name']}'");
961e974c
CW
275 }
276
277 /**
278 * Replaces value (or an array of values) with options from a pseudoconstant list.
279 *
280 * The direction of lookup defaults to transforming ids to option values for api output;
281 * for api input, set $reverse = TRUE to transform option values to ids.
282 *
283 * @param array $options
284 * @param string|string[] $value
285 * @param bool $reverse
286 * Is this a reverse lookup (for transforming input instead of output)
287 * @return array|mixed|null
288 */
289 public static function replacePseudoconstant($options, $value, $reverse = FALSE) {
290 $matches = [];
291 foreach ((array) $value as $val) {
292 if (!$reverse && isset($options[$val])) {
293 $matches[] = $options[$val];
294 }
295 elseif ($reverse && array_search($val, $options) !== FALSE) {
296 $matches[] = array_search($val, $options);
2929a8fb
CW
297 }
298 }
961e974c 299 return is_array($value) ? $matches : $matches[0] ?? NULL;
2929a8fb
CW
300 }
301
265192b2
CW
302 /**
303 * Apply a field's output_formatters callback functions
304 *
305 * @param array $result
306 * @param string $fieldPath
307 * @param array $field
308 * @param mixed $value
309 */
310 private static function applyFormatters(array $result, string $fieldPath, array $field, &$value) {
311 $row = self::filterByPrefix($result, $fieldPath, $field['name']);
312
96f09dda
CW
313 foreach ($field['output_formatters'] as $formatter) {
314 $formatter($value, $row, $field);
315 }
316 }
317
2929a8fb
CW
318 /**
319 * @param mixed $value
320 * @param string $dataType
321 * @return mixed
322 */
323 public static function convertDataType($value, $dataType) {
961e974c
CW
324 if (isset($value) && $dataType) {
325 if (is_array($value)) {
326 foreach ($value as $key => $val) {
327 $value[$key] = self::convertDataType($val, $dataType);
328 }
329 return $value;
330 }
331
2929a8fb
CW
332 switch ($dataType) {
333 case 'Boolean':
334 return (bool) $value;
335
336 case 'Integer':
337 return (int) $value;
338
339 case 'Money':
340 case 'Float':
341 return (float) $value;
4c5b9937
CW
342
343 case 'Date':
344 // Strip time from date-only fields
345 return substr($value, 0, 10);
2929a8fb
CW
346 }
347 }
348 return $value;
349 }
350
351 /**
d818aa7b
CW
352 * Lists all field names (including suffixed variants) that should be removed for a given contact type.
353 *
2929a8fb 354 * @param string $contactType
d818aa7b
CW
355 * Individual|Organization|Household
356 * @param string $prefix
357 * Path at which these fields are found, e.g. "address.contact."
2929a8fb
CW
358 * @return array
359 */
d818aa7b 360 public static function contactFieldsToRemove($contactType, $prefix) {
2929a8fb
CW
361 if (!isset(\Civi::$statics[__CLASS__][__FUNCTION__][$contactType])) {
362 \Civi::$statics[__CLASS__][__FUNCTION__][$contactType] = [];
363 foreach (\CRM_Contact_DAO_Contact::fields() as $field) {
364 if (!empty($field['contactType']) && $field['contactType'] != $contactType) {
365 \Civi::$statics[__CLASS__][__FUNCTION__][$contactType][] = $field['name'];
d818aa7b
CW
366 // Include suffixed variants like prefix_id:label
367 if (!empty($field['pseudoconstant'])) {
3ffbd21c 368 foreach (self::$pseudoConstantSuffixes as $suffix) {
d818aa7b
CW
369 \Civi::$statics[__CLASS__][__FUNCTION__][$contactType][] = $field['name'] . ':' . $suffix;
370 }
371 }
2929a8fb
CW
372 }
373 }
374 }
d818aa7b
CW
375 // Add prefix paths
376 return array_map(function($name) use ($prefix) {
377 return $prefix . $name;
378 }, \Civi::$statics[__CLASS__][__FUNCTION__][$contactType]);
2929a8fb
CW
379 }
380
265192b2
CW
381 /**
382 * Given a field belonging to either the main entity or a joined entity,
383 * and a values array of [path => value], this returns all values which share the same root path.
384 *
385 * Works by filtering array keys to only include those with the same prefix as a given field,
386 * stripping them of that prefix.
387 *
388 * Ex:
389 * ```
390 * $values = [
391 * 'first_name' => 'a',
392 * 'middle_name' => 'b',
393 * 'related_contact.first_name' => 'c',
394 * 'related_contact.last_name' => 'd',
395 * 'activity.subject' => 'e',
396 * ]
397 * $fieldPath = 'related_contact.id'
398 * $fieldName = 'id'
399 *
400 * filterByPrefix($values, $fieldPath, $fieldName)
401 * returns [
402 * 'first_name' => 'c',
403 * 'last_name' => 'd',
404 * ]
405 * ```
406 *
407 * @param array $values
408 * @param string $fieldPath
409 * @param string $fieldName
410 * @return array
411 */
412 public static function filterByPrefix(array $values, string $fieldPath, string $fieldName): array {
413 $filtered = [];
414 $prefix = substr($fieldPath, 0, strpos($fieldPath, $fieldName));
415 foreach ($values as $key => $val) {
416 if (!$prefix || strpos($key, $prefix) === 0) {
417 $filtered[substr($key, strlen($prefix))] = $val;
418 }
419 }
420 return $filtered;
421 }
422
19b53e5b 423}