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