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