Commit | Line | Data |
---|---|---|
19b53e5b | 1 | <?php |
380f3545 TO |
2 | |
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\Generic\Traits; |
14 | ||
c9e3994d | 15 | use Civi\Api4\CustomField; |
721c9da1 | 16 | use Civi\Api4\Service\Schema\Joinable\CustomGroupJoinable; |
19b53e5b | 17 | use Civi\Api4\Utils\FormattingUtil; |
a62d97f3 | 18 | use Civi\Api4\Utils\CoreUtil; |
5a443458 | 19 | use Civi\Api4\Utils\ReflectionUtils; |
19b53e5b C |
20 | |
21 | /** | |
22 | * @method string getLanguage() | |
14a9c588 | 23 | * @method $this setLanguage(string $language) |
19b53e5b C |
24 | */ |
25 | trait DAOActionTrait { | |
26 | ||
27 | /** | |
28 | * Specify the language to use if this is a multi-lingual environment. | |
29 | * | |
30 | * E.g. "en_US" or "fr_CA" | |
31 | * | |
32 | * @var string | |
33 | */ | |
34 | protected $language; | |
35 | ||
076fe09a CW |
36 | private $_maxWeights = []; |
37 | ||
19b53e5b C |
38 | /** |
39 | * @return \CRM_Core_DAO|string | |
40 | */ | |
41 | protected function getBaoName() { | |
a62d97f3 | 42 | return CoreUtil::getBAOFromApiName($this->getEntityName()); |
19b53e5b C |
43 | } |
44 | ||
45 | /** | |
a4c7afc0 | 46 | * Convert saved object to array |
19b53e5b | 47 | * |
a4c7afc0 CW |
48 | * Used by create, update & save actions |
49 | * | |
50 | * @param \CRM_Core_DAO $bao | |
51 | * @param array $input | |
19b53e5b C |
52 | * @return array |
53 | */ | |
a4c7afc0 CW |
54 | public function baoToArray($bao, $input) { |
55 | $allFields = array_column($bao->fields(), 'name'); | |
56 | if (!empty($this->reload)) { | |
57 | $inputFields = $allFields; | |
58 | $bao->find(TRUE); | |
59 | } | |
60 | else { | |
61 | $inputFields = array_keys($input); | |
62 | // Convert 'null' input to true null | |
63 | foreach ($input as $key => $val) { | |
64 | if ($val === 'null') { | |
65 | $bao->$key = NULL; | |
66 | } | |
67 | } | |
68 | } | |
19b53e5b | 69 | $values = []; |
a4c7afc0 CW |
70 | foreach ($allFields as $field) { |
71 | if (isset($bao->$field) || in_array($field, $inputFields)) { | |
72 | $values[$field] = $bao->$field ?? NULL; | |
19b53e5b C |
73 | } |
74 | } | |
75 | return $values; | |
76 | } | |
77 | ||
19b53e5b C |
78 | /** |
79 | * Fill field defaults which were declared by the api. | |
80 | * | |
81 | * Note: default values from core are ignored because the BAO or database layer will supply them. | |
82 | * | |
83 | * @param array $params | |
84 | */ | |
85 | protected function fillDefaults(&$params) { | |
86 | $fields = $this->entityFields(); | |
87 | $bao = $this->getBaoName(); | |
88 | $coreFields = array_column($bao::fields(), NULL, 'name'); | |
89 | ||
90 | foreach ($fields as $name => $field) { | |
91 | // If a default value in the api field is different than in core, the api should override it. | |
92 | if (!isset($params[$name]) && !empty($field['default_value']) && $field['default_value'] != \CRM_Utils_Array::pathGet($coreFields, [$name, 'default'])) { | |
93 | $params[$name] = $field['default_value']; | |
94 | } | |
95 | } | |
96 | } | |
97 | ||
98 | /** | |
700dda89 | 99 | * Write bao objects as part of a create/update/save action. |
19b53e5b C |
100 | * |
101 | * @param array $items | |
102 | * The records to write to the DB. | |
14a9c588 | 103 | * |
19b53e5b C |
104 | * @return array |
105 | * The records after being written to the DB (e.g. including newly assigned "id"). | |
106 | * @throws \API_Exception | |
14a9c588 | 107 | * @throws \CRM_Core_Exception |
19b53e5b | 108 | */ |
5a443458 | 109 | protected function writeObjects($items) { |
076fe09a | 110 | $updateWeights = FALSE; |
076fe09a CW |
111 | // Adjust weights for sortable entities |
112 | if (in_array('SortableEntity', CoreUtil::getInfoItem($this->getEntityName(), 'type'))) { | |
113 | $weightField = CoreUtil::getInfoItem($this->getEntityName(), 'order_by'); | |
114 | // Only take action if updating a single record, or if no weights are specified in any record | |
115 | // This avoids messing up a bulk update with multiple recalculations | |
116 | if (count($items) === 1 || !array_filter(array_column($items, $weightField))) { | |
117 | $updateWeights = TRUE; | |
118 | } | |
119 | } | |
120 | ||
19b53e5b C |
121 | $result = []; |
122 | ||
baf63a69 | 123 | foreach ($items as &$item) { |
2929a8fb | 124 | $entityId = $item['id'] ?? NULL; |
37d82abe | 125 | FormattingUtil::formatWriteParams($item, $this->entityFields()); |
19b53e5b | 126 | $this->formatCustomParams($item, $entityId); |
236f858e | 127 | |
076fe09a CW |
128 | // Adjust weights for sortable entities |
129 | if ($updateWeights) { | |
130 | $this->updateWeight($item); | |
131 | } | |
132 | ||
19b53e5b | 133 | $item['check_permissions'] = $this->getCheckPermissions(); |
5a443458 | 134 | } |
19b53e5b | 135 | |
5a443458 CW |
136 | // Ensure array keys start at 0 |
137 | $items = array_values($items); | |
19b53e5b | 138 | |
5a443458 CW |
139 | foreach ($this->write($items) as $index => $dao) { |
140 | if (!$dao) { | |
19b53e5b C |
141 | $errMessage = sprintf('%s write operation failed', $this->getEntityName()); |
142 | throw new \API_Exception($errMessage); | |
143 | } | |
5a443458 | 144 | $result[] = $this->baoToArray($dao, $items[$index]); |
236f858e CW |
145 | } |
146 | ||
30493ced | 147 | \CRM_Utils_API_HTMLInputCoder::singleton()->decodeRows($result); |
afb75a23 | 148 | FormattingUtil::formatOutputValues($result, $this->entityFields()); |
19b53e5b C |
149 | return $result; |
150 | } | |
151 | ||
5a443458 CW |
152 | /** |
153 | * Overrideable function to save items using the appropriate BAO function | |
154 | * | |
155 | * @param array[] $items | |
156 | * Items already formatted by self::writeObjects | |
157 | * @return \CRM_Core_DAO[] | |
158 | * Array of saved DAO records | |
159 | */ | |
160 | protected function write(array $items) { | |
161 | $saved = []; | |
162 | $baoName = $this->getBaoName(); | |
163 | ||
164 | $method = method_exists($baoName, 'create') ? 'create' : (method_exists($baoName, 'add') ? 'add' : NULL); | |
165 | // Use BAO create or add method if not deprecated | |
166 | if ($method && !ReflectionUtils::isMethodDeprecated($baoName, $method)) { | |
167 | foreach ($items as $item) { | |
168 | $saved[] = $baoName::$method($item); | |
169 | } | |
170 | } | |
171 | else { | |
172 | $saved = $baoName::writeRecords($items); | |
173 | } | |
174 | return $saved; | |
175 | } | |
176 | ||
4fd13075 CW |
177 | /** |
178 | * @inheritDoc | |
179 | */ | |
180 | protected function formatWriteValues(&$record) { | |
181 | $this->resolveFKValues($record); | |
182 | parent::formatWriteValues($record); | |
183 | } | |
184 | ||
185 | /** | |
186 | * Looks up an id based on some other property of an fk entity | |
187 | * | |
188 | * @param array $record | |
189 | */ | |
190 | private function resolveFKValues(array &$record): void { | |
25af38fb CW |
191 | // Resolve domain id first |
192 | uksort($record, function($a, $b) { | |
193 | return substr($a, 0, 9) == 'domain_id' ? -1 : 1; | |
194 | }); | |
4fd13075 | 195 | foreach ($record as $key => $value) { |
25af38fb | 196 | if (!$value || substr_count($key, '.') !== 1) { |
4fd13075 CW |
197 | continue; |
198 | } | |
199 | [$fieldName, $fkField] = explode('.', $key); | |
200 | $field = $this->entityFields()[$fieldName] ?? NULL; | |
201 | if (!$field || empty($field['fk_entity'])) { | |
202 | continue; | |
203 | } | |
204 | $fkDao = CoreUtil::getBAOFromApiName($field['fk_entity']); | |
25af38fb CW |
205 | // Constrain search to the domain of the current entity |
206 | $domainConstraint = NULL; | |
207 | if (isset($fkDao::getSupportedFields()['domain_id'])) { | |
208 | if (!empty($record['domain_id'])) { | |
209 | $domainConstraint = $record['domain_id'] === 'current_domain' ? \CRM_Core_Config::domainID() : $record['domain_id']; | |
210 | } | |
211 | elseif (!empty($record['id']) && isset($this->entityFields()['domain_id'])) { | |
212 | $domainConstraint = \CRM_Core_DAO::getFieldValue($this->getBaoName(), $record['id'], 'domain_id'); | |
213 | } | |
214 | } | |
215 | if ($domainConstraint) { | |
216 | $fkSearch = new $fkDao(); | |
217 | $fkSearch->domain_id = $domainConstraint; | |
218 | $fkSearch->$fkField = $value; | |
219 | $fkSearch->find(TRUE); | |
220 | $record[$fieldName] = $fkSearch->id; | |
221 | } | |
222 | // Simple lookup without all the fuss about domains | |
223 | else { | |
224 | $record[$fieldName] = \CRM_Core_DAO::getFieldValue($fkDao, $value, 'id', $fkField); | |
225 | } | |
4fd13075 CW |
226 | unset($record[$key]); |
227 | } | |
228 | } | |
229 | ||
19b53e5b | 230 | /** |
932303e6 CW |
231 | * Converts params from flat array e.g. ['GroupName.Fieldname' => value] to the |
232 | * hierarchy expected by the BAO, nested within $params['custom']. | |
233 | * | |
19b53e5b C |
234 | * @param array $params |
235 | * @param int $entityId | |
14a9c588 | 236 | * |
14a9c588 | 237 | * @throws \API_Exception |
238 | * @throws \CRM_Core_Exception | |
19b53e5b C |
239 | */ |
240 | protected function formatCustomParams(&$params, $entityId) { | |
241 | $customParams = []; | |
242 | ||
19b53e5b | 243 | foreach ($params as $name => $value) { |
c9e3994d CW |
244 | $field = $this->getCustomFieldInfo($name); |
245 | if (!$field) { | |
19b53e5b C |
246 | continue; |
247 | } | |
248 | ||
540c0ec9 CW |
249 | // Null and empty string are interchangeable as far as the custom bao understands |
250 | if (NULL === $value) { | |
251 | $value = ''; | |
252 | } | |
19b53e5b | 253 | |
540c0ec9 CW |
254 | if ($field['suffix']) { |
255 | $options = FormattingUtil::getPseudoconstantList($field, $name, $params, $this->getActionName()); | |
256 | $value = FormattingUtil::replacePseudoconstant($options, $value, TRUE); | |
257 | } | |
961e974c | 258 | |
540c0ec9 CW |
259 | if ($field['html_type'] === 'CheckBox') { |
260 | // this function should be part of a class | |
261 | formatCheckBoxField($value, 'custom_' . $field['id'], $this->getEntityName()); | |
262 | } | |
19b53e5b | 263 | |
540c0ec9 CW |
264 | // Match contact id to strings like "user_contact_id" |
265 | // FIXME handle arrays for multi-value contact reference fields, etc. | |
266 | if ($field['data_type'] === 'ContactReference' && is_string($value) && !is_numeric($value)) { | |
267 | // FIXME decouple from v3 API | |
268 | require_once 'api/v3/utils.php'; | |
269 | $value = \_civicrm_api3_resolve_contactID($value); | |
270 | if ('unknown-user' === $value) { | |
271 | throw new \API_Exception("\"{$field['name']}\" \"{$value}\" cannot be resolved to a contact ID", 2002, ['error_field' => $field['name'], "type" => "integer"]); | |
a6a158e7 | 272 | } |
19b53e5b | 273 | } |
540c0ec9 CW |
274 | |
275 | \CRM_Core_BAO_CustomField::formatCustomField( | |
276 | $field['id'], | |
277 | $customParams, | |
278 | $value, | |
279 | $field['custom_group_id.extends'], | |
280 | // todo check when this is needed | |
281 | NULL, | |
282 | $entityId, | |
283 | FALSE, | |
284 | $this->getCheckPermissions(), | |
285 | TRUE | |
286 | ); | |
19b53e5b C |
287 | } |
288 | ||
317103ab | 289 | $params['custom'] = $customParams ?: NULL; |
19b53e5b C |
290 | } |
291 | ||
c9e3994d CW |
292 | /** |
293 | * Gets field info needed to save custom data | |
294 | * | |
721c9da1 | 295 | * @param string $fieldExpr |
c9e3994d | 296 | * Field identifier with possible suffix, e.g. MyCustomGroup.MyField1:label |
932303e6 | 297 | * @return array{id: int, name: string, entity: string, suffix: string, html_type: string, data_type: string}|NULL |
c9e3994d | 298 | */ |
721c9da1 CW |
299 | protected function getCustomFieldInfo(string $fieldExpr) { |
300 | if (strpos($fieldExpr, '.') === FALSE) { | |
c9e3994d CW |
301 | return NULL; |
302 | } | |
265192b2 CW |
303 | [$groupName, $fieldName] = explode('.', $fieldExpr); |
304 | [$fieldName, $suffix] = array_pad(explode(':', $fieldName), 2, NULL); | |
721c9da1 CW |
305 | $cacheKey = "APIv4_Custom_Fields-$groupName"; |
306 | $info = \Civi::cache('metadata')->get($cacheKey); | |
307 | if (!isset($info[$fieldName])) { | |
308 | $info = []; | |
309 | $fields = CustomField::get(FALSE) | |
84ad7693 CW |
310 | ->addSelect('id', 'name', 'html_type', 'data_type', 'custom_group_id.extends') |
311 | ->addWhere('custom_group_id.name', '=', $groupName) | |
c9e3994d | 312 | ->execute()->indexBy('name'); |
721c9da1 CW |
313 | foreach ($fields as $name => $field) { |
314 | $field['custom_field_id'] = $field['id']; | |
315 | $field['name'] = $groupName . '.' . $name; | |
84ad7693 | 316 | $field['entity'] = CustomGroupJoinable::getEntityFromExtends($field['custom_group_id.extends']); |
721c9da1 CW |
317 | $info[$name] = $field; |
318 | } | |
319 | \Civi::cache('metadata')->set($cacheKey, $info); | |
c9e3994d | 320 | } |
721c9da1 | 321 | return isset($info[$fieldName]) ? ['suffix' => $suffix] + $info[$fieldName] : NULL; |
c9e3994d CW |
322 | } |
323 | ||
076fe09a CW |
324 | /** |
325 | * Update weights when inserting or updating a sortable entity | |
326 | * @param array $record | |
327 | * @see SortableEntity | |
328 | */ | |
329 | protected function updateWeight(array &$record) { | |
330 | /** @var \CRM_Core_DAO|string $daoName */ | |
331 | $daoName = CoreUtil::getInfoItem($this->getEntityName(), 'dao'); | |
332 | $weightField = CoreUtil::getInfoItem($this->getEntityName(), 'order_by'); | |
333 | $idField = CoreUtil::getIdFieldName($this->getEntityName()); | |
334 | // If updating an existing record without changing weight, do nothing | |
335 | if (!isset($record[$weightField]) && !empty($record[$idField])) { | |
336 | return; | |
337 | } | |
338 | $daoFields = $daoName::getSupportedFields(); | |
339 | $newWeight = $record[$weightField] ?? NULL; | |
340 | $oldWeight = empty($record[$idField]) ? NULL : \CRM_Core_DAO::getFieldValue($daoName, $record[$idField], $weightField); | |
341 | ||
342 | // FIXME: Need a more metadata-ish approach. For now here's a hardcoded list of the fields sortable entities use for grouping. | |
9df894bb | 343 | $guesses = ['option_group_id', 'price_set_id', 'price_field_id', 'premiums_id', 'uf_group_id', 'custom_group_id', 'parent_id', 'domain_id']; |
076fe09a CW |
344 | $filters = []; |
345 | foreach (array_intersect($guesses, array_keys($daoFields)) as $filter) { | |
9df894bb | 346 | $filters[$filter] = $record[$filter] ?? (empty($record[$idField]) ? NULL : \CRM_Core_DAO::getFieldValue($daoName, $record[$idField], $filter)); |
076fe09a CW |
347 | } |
348 | // Supply default weight for new record | |
349 | if (!isset($record[$weightField]) && empty($record[$idField])) { | |
350 | $record[$weightField] = $this->getMaxWeight($daoName, $filters, $weightField); | |
351 | } | |
352 | else { | |
353 | $record[$weightField] = \CRM_Utils_Weight::updateOtherWeights($daoName, $oldWeight, $newWeight, $filters, $weightField); | |
354 | } | |
355 | } | |
356 | ||
357 | /** | |
358 | * Looks up max weight for a set of sortable entities | |
359 | * | |
360 | * Keeps it in memory in case this operation is writing more than one record | |
361 | * | |
362 | * @param $daoName | |
363 | * @param $filters | |
364 | * @param $weightField | |
365 | * @return int|mixed | |
366 | */ | |
367 | private function getMaxWeight($daoName, $filters, $weightField) { | |
368 | $key = $daoName . json_encode($filters); | |
369 | if (!isset($this->_maxWeights[$key])) { | |
370 | $this->_maxWeights[$key] = \CRM_Utils_Weight::getMax($daoName, $filters, $weightField) + 1; | |
371 | } | |
372 | else { | |
373 | ++$this->_maxWeights[$key]; | |
374 | } | |
375 | return $this->_maxWeights[$key]; | |
376 | } | |
377 | ||
19b53e5b | 378 | } |