4 +--------------------------------------------------------------------+
5 | Copyright CiviCRM LLC. All rights reserved. |
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 +--------------------------------------------------------------------+
13 namespace Civi\Api4\Generic\Traits
;
15 use Civi\Api4\CustomField
;
16 use Civi\Api4\Service\Schema\Joinable\CustomGroupJoinable
;
17 use Civi\Api4\Utils\FormattingUtil
;
18 use Civi\Api4\Utils\CoreUtil
;
21 * @method string getLanguage()
22 * @method $this setLanguage(string $language)
24 trait DAOActionTrait
{
27 * Specify the language to use if this is a multi-lingual environment.
29 * E.g. "en_US" or "fr_CA"
35 private $_maxWeights = [];
38 * @return \CRM_Core_DAO|string
40 protected function getBaoName() {
41 return CoreUtil
::getBAOFromApiName($this->getEntityName());
45 * Convert saved object to array
47 * Used by create, update & save actions
49 * @param \CRM_Core_DAO $bao
53 public function baoToArray($bao, $input) {
54 $allFields = array_column($bao->fields(), 'name');
55 if (!empty($this->reload
)) {
56 $inputFields = $allFields;
60 $inputFields = array_keys($input);
61 // Convert 'null' input to true null
62 foreach ($input as $key => $val) {
63 if ($val === 'null') {
69 foreach ($allFields as $field) {
70 if (isset($bao->$field) ||
in_array($field, $inputFields)) {
71 $values[$field] = $bao->$field ??
NULL;
78 * Fill field defaults which were declared by the api.
80 * Note: default values from core are ignored because the BAO or database layer will supply them.
82 * @param array $params
84 protected function fillDefaults(&$params) {
85 $fields = $this->entityFields();
86 $bao = $this->getBaoName();
87 $coreFields = array_column($bao::fields(), NULL, 'name');
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'];
98 * Write bao objects as part of a create/update action.
100 * @param array $items
101 * The records to write to the DB.
104 * The records after being written to the DB (e.g. including newly assigned "id").
105 * @throws \API_Exception
106 * @throws \CRM_Core_Exception
108 protected function writeObjects(&$items) {
109 $baoName = $this->getBaoName();
110 $updateWeights = FALSE;
112 // TODO: Opt-in more entities to use the new writeRecords BAO method.
115 'CustomField' => 'writeRecords',
116 'EntityTag' => 'add',
117 'GroupContact' => 'add',
118 'Navigation' => 'writeRecords',
120 $method = $functionNames[$this->getEntityName()] ??
NULL;
121 if (!isset($method)) {
122 $method = method_exists($baoName, 'create') ?
'create' : (method_exists($baoName, 'add') ?
'add' : 'writeRecords');
125 // Adjust weights for sortable entities
126 if (in_array('SortableEntity', CoreUtil
::getInfoItem($this->getEntityName(), 'type'))) {
127 $weightField = CoreUtil
::getInfoItem($this->getEntityName(), 'order_by');
128 // Only take action if updating a single record, or if no weights are specified in any record
129 // This avoids messing up a bulk update with multiple recalculations
130 if (count($items) === 1 ||
!array_filter(array_column($items, $weightField))) {
131 $updateWeights = TRUE;
137 foreach ($items as &$item) {
138 $entityId = $item['id'] ??
NULL;
139 FormattingUtil
::formatWriteParams($item, $this->entityFields());
140 $this->formatCustomParams($item, $entityId);
142 // Adjust weights for sortable entities
143 if ($updateWeights) {
144 $this->updateWeight($item);
147 // Skip individual processing if using writeRecords
148 if ($method === 'writeRecords') {
151 $item['check_permissions'] = $this->getCheckPermissions();
153 // For some reason the contact bao requires this
154 if ($entityId && $this->getEntityName() === 'Contact') {
155 $item['contact_id'] = $entityId;
158 if ($this->getEntityName() === 'Address') {
159 $createResult = $baoName::$method($item, $this->fixAddress
);
162 $createResult = $baoName::$method($item);
165 if (!$createResult) {
166 $errMessage = sprintf('%s write operation failed', $this->getEntityName());
167 throw new \
API_Exception($errMessage);
170 $result[] = $this->baoToArray($createResult, $item);
171 \CRM_Utils_API_HTMLInputCoder
::singleton()->decodeRows($result);
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
176 if ($method === 'writeRecords') {
177 $items = array_values($items);
178 foreach ($baoName::writeRecords($items) as $i => $createResult) {
179 $result[] = $this->baoToArray($createResult, $items[$i]);
183 FormattingUtil
::formatOutputValues($result, $this->entityFields());
190 protected function formatWriteValues(&$record) {
191 $this->resolveFKValues($record);
192 parent
::formatWriteValues($record);
196 * Looks up an id based on some other property of an fk entity
198 * @param array $record
200 private function resolveFKValues(array &$record): void
{
201 // Resolve domain id first
202 uksort($record, function($a, $b) {
203 return substr($a, 0, 9) == 'domain_id' ?
-1 : 1;
205 foreach ($record as $key => $value) {
206 if (!$value ||
substr_count($key, '.') !== 1) {
209 [$fieldName, $fkField] = explode('.', $key);
210 $field = $this->entityFields()[$fieldName] ??
NULL;
211 if (!$field ||
empty($field['fk_entity'])) {
214 $fkDao = CoreUtil
::getBAOFromApiName($field['fk_entity']);
215 // Constrain search to the domain of the current entity
216 $domainConstraint = NULL;
217 if (isset($fkDao::getSupportedFields()['domain_id'])) {
218 if (!empty($record['domain_id'])) {
219 $domainConstraint = $record['domain_id'] === 'current_domain' ? \CRM_Core_Config
::domainID() : $record['domain_id'];
221 elseif (!empty($record['id']) && isset($this->entityFields()['domain_id'])) {
222 $domainConstraint = \CRM_Core_DAO
::getFieldValue($this->getBaoName(), $record['id'], 'domain_id');
225 if ($domainConstraint) {
226 $fkSearch = new $fkDao();
227 $fkSearch->domain_id
= $domainConstraint;
228 $fkSearch->$fkField = $value;
229 $fkSearch->find(TRUE);
230 $record[$fieldName] = $fkSearch->id
;
232 // Simple lookup without all the fuss about domains
234 $record[$fieldName] = \CRM_Core_DAO
::getFieldValue($fkDao, $value, 'id', $fkField);
236 unset($record[$key]);
241 * Converts params from flat array e.g. ['GroupName.Fieldname' => value] to the
242 * hierarchy expected by the BAO, nested within $params['custom'].
244 * @param array $params
245 * @param int $entityId
247 * @throws \API_Exception
248 * @throws \CRM_Core_Exception
250 protected function formatCustomParams(&$params, $entityId) {
253 foreach ($params as $name => $value) {
254 $field = $this->getCustomFieldInfo($name);
259 // todo are we sure we don't want to allow setting to NULL? need to test
260 if (NULL !== $value) {
262 if ($field['suffix']) {
263 $options = FormattingUtil
::getPseudoconstantList($field, $name, $params, $this->getActionName());
264 $value = FormattingUtil
::replacePseudoconstant($options, $value, TRUE);
267 if ($field['html_type'] === 'CheckBox') {
268 // this function should be part of a class
269 formatCheckBoxField($value, 'custom_' . $field['id'], $this->getEntityName());
272 // Match contact id to strings like "user_contact_id"
273 // FIXME handle arrays for multi-value contact reference fields, etc.
274 if ($field['data_type'] === 'ContactReference' && is_string($value) && !is_numeric($value)) {
275 // FIXME decouple from v3 API
276 require_once 'api/v3/utils.php';
277 $value = \
_civicrm_api3_resolve_contactID($value);
278 if ('unknown-user' === $value) {
279 throw new \
API_Exception("\"{$field['name']}\" \"{$value}\" cannot be resolved to a contact ID", 2002, ['error_field' => $field['name'], "type" => "integer"]);
283 \CRM_Core_BAO_CustomField
::formatCustomField(
287 $field['custom_group_id.extends'],
288 // todo check when this is needed
292 $this->getCheckPermissions(),
298 $params['custom'] = $customParams ?
: NULL;
302 * Gets field info needed to save custom data
304 * @param string $fieldExpr
305 * Field identifier with possible suffix, e.g. MyCustomGroup.MyField1:label
306 * @return array{id: int, name: string, entity: string, suffix: string, html_type: string, data_type: string}|NULL
308 protected function getCustomFieldInfo(string $fieldExpr) {
309 if (strpos($fieldExpr, '.') === FALSE) {
312 [$groupName, $fieldName] = explode('.', $fieldExpr);
313 [$fieldName, $suffix] = array_pad(explode(':', $fieldName), 2, NULL);
314 $cacheKey = "APIv4_Custom_Fields-$groupName";
315 $info = \Civi
::cache('metadata')->get($cacheKey);
316 if (!isset($info[$fieldName])) {
318 $fields = CustomField
::get(FALSE)
319 ->addSelect('id', 'name', 'html_type', 'data_type', 'custom_group_id.extends')
320 ->addWhere('custom_group_id.name', '=', $groupName)
321 ->execute()->indexBy('name');
322 foreach ($fields as $name => $field) {
323 $field['custom_field_id'] = $field['id'];
324 $field['name'] = $groupName . '.' . $name;
325 $field['entity'] = CustomGroupJoinable
::getEntityFromExtends($field['custom_group_id.extends']);
326 $info[$name] = $field;
328 \Civi
::cache('metadata')->set($cacheKey, $info);
330 return isset($info[$fieldName]) ?
['suffix' => $suffix] +
$info[$fieldName] : NULL;
334 * Update weights when inserting or updating a sortable entity
335 * @param array $record
336 * @see SortableEntity
338 protected function updateWeight(array &$record) {
339 /** @var \CRM_Core_DAO|string $daoName */
340 $daoName = CoreUtil
::getInfoItem($this->getEntityName(), 'dao');
341 $weightField = CoreUtil
::getInfoItem($this->getEntityName(), 'order_by');
342 $idField = CoreUtil
::getIdFieldName($this->getEntityName());
343 // If updating an existing record without changing weight, do nothing
344 if (!isset($record[$weightField]) && !empty($record[$idField])) {
347 $daoFields = $daoName::getSupportedFields();
348 $newWeight = $record[$weightField] ??
NULL;
349 $oldWeight = empty($record[$idField]) ?
NULL : \CRM_Core_DAO
::getFieldValue($daoName, $record[$idField], $weightField);
351 // FIXME: Need a more metadata-ish approach. For now here's a hardcoded list of the fields sortable entities use for grouping.
352 $guesses = ['option_group_id', 'price_set_id', 'price_field_id', 'premiums_id', 'uf_group_id', 'custom_group_id', 'parent_id', 'domain_id'];
354 foreach (array_intersect($guesses, array_keys($daoFields)) as $filter) {
355 $filters[$filter] = $record[$filter] ??
(empty($record[$idField]) ?
NULL : \CRM_Core_DAO
::getFieldValue($daoName, $record[$idField], $filter));
357 // Supply default weight for new record
358 if (!isset($record[$weightField]) && empty($record[$idField])) {
359 $record[$weightField] = $this->getMaxWeight($daoName, $filters, $weightField);
362 $record[$weightField] = \CRM_Utils_Weight
::updateOtherWeights($daoName, $oldWeight, $newWeight, $filters, $weightField);
367 * Looks up max weight for a set of sortable entities
369 * Keeps it in memory in case this operation is writing more than one record
373 * @param $weightField
376 private function getMaxWeight($daoName, $filters, $weightField) {
377 $key = $daoName . json_encode($filters);
378 if (!isset($this->_maxWeights
[$key])) {
379 $this->_maxWeights
[$key] = \CRM_Utils_Weight
::getMax($daoName, $filters, $weightField) +
1;
382 ++
$this->_maxWeights
[$key];
384 return $this->_maxWeights
[$key];