APIv4 - Add SortableEntity type which auto-adjusts weights
[civicrm-core.git] / Civi / Api4 / Generic / Traits / DAOActionTrait.php
1 <?php
2
3 /*
4 +--------------------------------------------------------------------+
5 | Copyright CiviCRM LLC. All rights reserved. |
6 | |
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 +--------------------------------------------------------------------+
11 */
12
13 namespace Civi\Api4\Generic\Traits;
14
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;
19
20 /**
21 * @method string getLanguage()
22 * @method $this setLanguage(string $language)
23 */
24 trait 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
35 private $_maxWeights = [];
36
37 /**
38 * @return \CRM_Core_DAO|string
39 */
40 protected function getBaoName() {
41 return CoreUtil::getBAOFromApiName($this->getEntityName());
42 }
43
44 /**
45 * Convert saved object to array
46 *
47 * Used by create, update & save actions
48 *
49 * @param \CRM_Core_DAO $bao
50 * @param array $input
51 * @return array
52 */
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 }
68 $values = [];
69 foreach ($allFields as $field) {
70 if (isset($bao->$field) || in_array($field, $inputFields)) {
71 $values[$field] = $bao->$field ?? NULL;
72 }
73 }
74 return $values;
75 }
76
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 /**
98 * Write bao objects as part of a create/update action.
99 *
100 * @param array $items
101 * The records to write to the DB.
102 *
103 * @return array
104 * The records after being written to the DB (e.g. including newly assigned "id").
105 * @throws \API_Exception
106 * @throws \CRM_Core_Exception
107 */
108 protected function writeObjects(&$items) {
109 $baoName = $this->getBaoName();
110 $updateWeights = FALSE;
111
112 // TODO: Opt-in more entities to use the new writeRecords BAO method.
113 $functionNames = [
114 'Address' => 'add',
115 'CustomField' => 'writeRecords',
116 'EntityTag' => 'add',
117 'GroupContact' => 'add',
118 ];
119 $method = $functionNames[$this->getEntityName()] ?? NULL;
120 if (!isset($method)) {
121 $method = method_exists($baoName, 'create') ? 'create' : (method_exists($baoName, 'add') ? 'add' : 'writeRecords');
122 }
123
124 // Adjust weights for sortable entities
125 if (in_array('SortableEntity', CoreUtil::getInfoItem($this->getEntityName(), 'type'))) {
126 $weightField = CoreUtil::getInfoItem($this->getEntityName(), 'order_by');
127 // Only take action if updating a single record, or if no weights are specified in any record
128 // This avoids messing up a bulk update with multiple recalculations
129 if (count($items) === 1 || !array_filter(array_column($items, $weightField))) {
130 $updateWeights = TRUE;
131 }
132 }
133
134 $result = [];
135
136 foreach ($items as &$item) {
137 $entityId = $item['id'] ?? NULL;
138 FormattingUtil::formatWriteParams($item, $this->entityFields());
139 $this->formatCustomParams($item, $entityId);
140
141 // Adjust weights for sortable entities
142 if ($updateWeights) {
143 $this->updateWeight($item);
144 }
145
146 // Skip individual processing if using writeRecords
147 if ($method === 'writeRecords') {
148 continue;
149 }
150 $item['check_permissions'] = $this->getCheckPermissions();
151
152 // For some reason the contact bao requires this
153 if ($entityId && $this->getEntityName() === 'Contact') {
154 $item['contact_id'] = $entityId;
155 }
156
157 if ($this->getEntityName() === 'Address') {
158 $createResult = $baoName::$method($item, $this->fixAddress);
159 }
160 else {
161 $createResult = $baoName::$method($item);
162 }
163
164 if (!$createResult) {
165 $errMessage = sprintf('%s write operation failed', $this->getEntityName());
166 throw new \API_Exception($errMessage);
167 }
168
169 $result[] = $this->baoToArray($createResult, $item);
170 \CRM_Utils_API_HTMLInputCoder::singleton()->decodeRows($result);
171 }
172
173 // Use bulk `writeRecords` method if the BAO doesn't have a create or add method
174 // TODO: reverse this from opt-in to opt-out and default to using `writeRecords` for all BAOs
175 if ($method === 'writeRecords') {
176 $items = array_values($items);
177 foreach ($baoName::writeRecords($items) as $i => $createResult) {
178 $result[] = $this->baoToArray($createResult, $items[$i]);
179 }
180 }
181
182 FormattingUtil::formatOutputValues($result, $this->entityFields());
183 return $result;
184 }
185
186 /**
187 * @inheritDoc
188 */
189 protected function formatWriteValues(&$record) {
190 $this->resolveFKValues($record);
191 parent::formatWriteValues($record);
192 }
193
194 /**
195 * Looks up an id based on some other property of an fk entity
196 *
197 * @param array $record
198 */
199 private function resolveFKValues(array &$record): void {
200 foreach ($record as $key => $value) {
201 if (substr_count($key, '.') !== 1) {
202 continue;
203 }
204 [$fieldName, $fkField] = explode('.', $key);
205 $field = $this->entityFields()[$fieldName] ?? NULL;
206 if (!$field || empty($field['fk_entity'])) {
207 continue;
208 }
209 $fkDao = CoreUtil::getBAOFromApiName($field['fk_entity']);
210 $record[$fieldName] = \CRM_Core_DAO::getFieldValue($fkDao, $value, 'id', $fkField);
211 unset($record[$key]);
212 }
213 }
214
215 /**
216 * Converts params from flat array e.g. ['GroupName.Fieldname' => value] to the
217 * hierarchy expected by the BAO, nested within $params['custom'].
218 *
219 * @param array $params
220 * @param int $entityId
221 *
222 * @throws \API_Exception
223 * @throws \CRM_Core_Exception
224 */
225 protected function formatCustomParams(&$params, $entityId) {
226 $customParams = [];
227
228 foreach ($params as $name => $value) {
229 $field = $this->getCustomFieldInfo($name);
230 if (!$field) {
231 continue;
232 }
233
234 // todo are we sure we don't want to allow setting to NULL? need to test
235 if (NULL !== $value) {
236
237 if ($field['suffix']) {
238 $options = FormattingUtil::getPseudoconstantList($field, $name, $params, $this->getActionName());
239 $value = FormattingUtil::replacePseudoconstant($options, $value, TRUE);
240 }
241
242 if ($field['html_type'] === 'CheckBox') {
243 // this function should be part of a class
244 formatCheckBoxField($value, 'custom_' . $field['id'], $this->getEntityName());
245 }
246
247 // Match contact id to strings like "user_contact_id"
248 // FIXME handle arrays for multi-value contact reference fields, etc.
249 if ($field['data_type'] === 'ContactReference' && is_string($value) && !is_numeric($value)) {
250 // FIXME decouple from v3 API
251 require_once 'api/v3/utils.php';
252 $value = \_civicrm_api3_resolve_contactID($value);
253 if ('unknown-user' === $value) {
254 throw new \API_Exception("\"{$field['name']}\" \"{$value}\" cannot be resolved to a contact ID", 2002, ['error_field' => $field['name'], "type" => "integer"]);
255 }
256 }
257
258 \CRM_Core_BAO_CustomField::formatCustomField(
259 $field['id'],
260 $customParams,
261 $value,
262 $field['custom_group_id.extends'],
263 // todo check when this is needed
264 NULL,
265 $entityId,
266 FALSE,
267 $this->getCheckPermissions(),
268 TRUE
269 );
270 }
271 }
272
273 $params['custom'] = $customParams ?: NULL;
274 }
275
276 /**
277 * Gets field info needed to save custom data
278 *
279 * @param string $fieldExpr
280 * Field identifier with possible suffix, e.g. MyCustomGroup.MyField1:label
281 * @return array{id: int, name: string, entity: string, suffix: string, html_type: string, data_type: string}|NULL
282 */
283 protected function getCustomFieldInfo(string $fieldExpr) {
284 if (strpos($fieldExpr, '.') === FALSE) {
285 return NULL;
286 }
287 [$groupName, $fieldName] = explode('.', $fieldExpr);
288 [$fieldName, $suffix] = array_pad(explode(':', $fieldName), 2, NULL);
289 $cacheKey = "APIv4_Custom_Fields-$groupName";
290 $info = \Civi::cache('metadata')->get($cacheKey);
291 if (!isset($info[$fieldName])) {
292 $info = [];
293 $fields = CustomField::get(FALSE)
294 ->addSelect('id', 'name', 'html_type', 'data_type', 'custom_group_id.extends')
295 ->addWhere('custom_group_id.name', '=', $groupName)
296 ->execute()->indexBy('name');
297 foreach ($fields as $name => $field) {
298 $field['custom_field_id'] = $field['id'];
299 $field['name'] = $groupName . '.' . $name;
300 $field['entity'] = CustomGroupJoinable::getEntityFromExtends($field['custom_group_id.extends']);
301 $info[$name] = $field;
302 }
303 \Civi::cache('metadata')->set($cacheKey, $info);
304 }
305 return isset($info[$fieldName]) ? ['suffix' => $suffix] + $info[$fieldName] : NULL;
306 }
307
308 /**
309 * Update weights when inserting or updating a sortable entity
310 * @param array $record
311 * @see SortableEntity
312 */
313 protected function updateWeight(array &$record) {
314 /** @var \CRM_Core_DAO|string $daoName */
315 $daoName = CoreUtil::getInfoItem($this->getEntityName(), 'dao');
316 $weightField = CoreUtil::getInfoItem($this->getEntityName(), 'order_by');
317 $idField = CoreUtil::getIdFieldName($this->getEntityName());
318 // If updating an existing record without changing weight, do nothing
319 if (!isset($record[$weightField]) && !empty($record[$idField])) {
320 return;
321 }
322 $daoFields = $daoName::getSupportedFields();
323 $newWeight = $record[$weightField] ?? NULL;
324 $oldWeight = empty($record[$idField]) ? NULL : \CRM_Core_DAO::getFieldValue($daoName, $record[$idField], $weightField);
325
326 // FIXME: Need a more metadata-ish approach. For now here's a hardcoded list of the fields sortable entities use for grouping.
327 $guesses = ['option_group_id', 'price_set_id', 'price_field_id', 'premiums_id', 'uf_group_id', 'custom_group_id', 'domain_id'];
328 $filters = [];
329 foreach (array_intersect($guesses, array_keys($daoFields)) as $filter) {
330 $value = $record[$filter] ?? (empty($record[$idField]) ? NULL : \CRM_Core_DAO::getFieldValue($daoName, $record[$idField], $filter));
331 // Ignore the db-formatted string 'null' and empty strings as well as NULL values
332 if (!\CRM_Utils_System::isNull($value)) {
333 $filters[$filter] = $value;
334 }
335 }
336 // Supply default weight for new record
337 if (!isset($record[$weightField]) && empty($record[$idField])) {
338 $record[$weightField] = $this->getMaxWeight($daoName, $filters, $weightField);
339 }
340 else {
341 $record[$weightField] = \CRM_Utils_Weight::updateOtherWeights($daoName, $oldWeight, $newWeight, $filters, $weightField);
342 }
343 }
344
345 /**
346 * Looks up max weight for a set of sortable entities
347 *
348 * Keeps it in memory in case this operation is writing more than one record
349 *
350 * @param $daoName
351 * @param $filters
352 * @param $weightField
353 * @return int|mixed
354 */
355 private function getMaxWeight($daoName, $filters, $weightField) {
356 $key = $daoName . json_encode($filters);
357 if (!isset($this->_maxWeights[$key])) {
358 $this->_maxWeights[$key] = \CRM_Utils_Weight::getMax($daoName, $filters, $weightField) + 1;
359 }
360 else {
361 ++$this->_maxWeights[$key];
362 }
363 return $this->_maxWeights[$key];
364 }
365
366 }