APIv4 - Refactor writeObjects to choose BAO function based on annotations
[civicrm-core.git] / Civi / Api4 / Generic / Traits / DAOActionTrait.php
CommitLineData
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
13namespace Civi\Api4\Generic\Traits;
14
c9e3994d 15use Civi\Api4\CustomField;
721c9da1 16use Civi\Api4\Service\Schema\Joinable\CustomGroupJoinable;
19b53e5b 17use Civi\Api4\Utils\FormattingUtil;
a62d97f3 18use Civi\Api4\Utils\CoreUtil;
5a443458 19use Civi\Api4\Utils\ReflectionUtils;
19b53e5b
C
20
21/**
22 * @method string getLanguage()
14a9c588 23 * @method $this setLanguage(string $language)
19b53e5b
C
24 */
25trait 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}