Commit | Line | Data |
---|---|---|
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 |
13 | namespace Civi\Api4\Utils; |
14 | ||
7ce7b1cd CW |
15 | use Civi\Api4\Query\SqlExpression; |
16 | ||
19b53e5b C |
17 | require_once 'api/v3/utils.php'; |
18 | ||
19 | class 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 | } |