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; |
19b53e5b C |
19 | |
20 | /** | |
21 | * @method string getLanguage() | |
14a9c588 | 22 | * @method $this setLanguage(string $language) |
19b53e5b C |
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 | ||
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 | /** | |
98 | * Write bao objects as part of a create/update action. | |
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); |
067dea45 | 172 | \CRM_Utils_API_HTMLInputCoder::singleton()->decodeRows($result); |
19b53e5b | 173 | } |
236f858e CW |
174 | |
175 | // Use bulk `writeRecords` method if the BAO doesn't have a create or add method | |
176 | // TODO: reverse this from opt-in to opt-out and default to using `writeRecords` for all BAOs | |
bfa91094 | 177 | if ($method === 'writeRecords') { |
236f858e CW |
178 | $items = array_values($items); |
179 | foreach ($baoName::writeRecords($items) as $i => $createResult) { | |
180 | $result[] = $this->baoToArray($createResult, $items[$i]); | |
181 | } | |
182 | } | |
183 | ||
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 | ||
19b53e5b | 260 | // todo are we sure we don't want to allow setting to NULL? need to test |
c9e3994d | 261 | if (NULL !== $value) { |
19b53e5b | 262 | |
c9e3994d | 263 | if ($field['suffix']) { |
265192b2 | 264 | $options = FormattingUtil::getPseudoconstantList($field, $name, $params, $this->getActionName()); |
961e974c CW |
265 | $value = FormattingUtil::replacePseudoconstant($options, $value, TRUE); |
266 | } | |
267 | ||
c9e3994d | 268 | if ($field['html_type'] === 'CheckBox') { |
19b53e5b | 269 | // this function should be part of a class |
c9e3994d | 270 | formatCheckBoxField($value, 'custom_' . $field['id'], $this->getEntityName()); |
19b53e5b C |
271 | } |
272 | ||
ef9170e4 CW |
273 | // Match contact id to strings like "user_contact_id" |
274 | // FIXME handle arrays for multi-value contact reference fields, etc. | |
275 | if ($field['data_type'] === 'ContactReference' && is_string($value) && !is_numeric($value)) { | |
276 | // FIXME decouple from v3 API | |
a6a158e7 CW |
277 | require_once 'api/v3/utils.php'; |
278 | $value = \_civicrm_api3_resolve_contactID($value); | |
279 | if ('unknown-user' === $value) { | |
280 | throw new \API_Exception("\"{$field['name']}\" \"{$value}\" cannot be resolved to a contact ID", 2002, ['error_field' => $field['name'], "type" => "integer"]); | |
281 | } | |
282 | } | |
283 | ||
19b53e5b | 284 | \CRM_Core_BAO_CustomField::formatCustomField( |
c9e3994d | 285 | $field['id'], |
19b53e5b C |
286 | $customParams, |
287 | $value, | |
84ad7693 | 288 | $field['custom_group_id.extends'], |
19b53e5b C |
289 | // todo check when this is needed |
290 | NULL, | |
291 | $entityId, | |
292 | FALSE, | |
317103ab | 293 | $this->getCheckPermissions(), |
19b53e5b C |
294 | TRUE |
295 | ); | |
296 | } | |
297 | } | |
298 | ||
317103ab | 299 | $params['custom'] = $customParams ?: NULL; |
19b53e5b C |
300 | } |
301 | ||
c9e3994d CW |
302 | /** |
303 | * Gets field info needed to save custom data | |
304 | * | |
721c9da1 | 305 | * @param string $fieldExpr |
c9e3994d | 306 | * Field identifier with possible suffix, e.g. MyCustomGroup.MyField1:label |
932303e6 | 307 | * @return array{id: int, name: string, entity: string, suffix: string, html_type: string, data_type: string}|NULL |
c9e3994d | 308 | */ |
721c9da1 CW |
309 | protected function getCustomFieldInfo(string $fieldExpr) { |
310 | if (strpos($fieldExpr, '.') === FALSE) { | |
c9e3994d CW |
311 | return NULL; |
312 | } | |
265192b2 CW |
313 | [$groupName, $fieldName] = explode('.', $fieldExpr); |
314 | [$fieldName, $suffix] = array_pad(explode(':', $fieldName), 2, NULL); | |
721c9da1 CW |
315 | $cacheKey = "APIv4_Custom_Fields-$groupName"; |
316 | $info = \Civi::cache('metadata')->get($cacheKey); | |
317 | if (!isset($info[$fieldName])) { | |
318 | $info = []; | |
319 | $fields = CustomField::get(FALSE) | |
84ad7693 CW |
320 | ->addSelect('id', 'name', 'html_type', 'data_type', 'custom_group_id.extends') |
321 | ->addWhere('custom_group_id.name', '=', $groupName) | |
c9e3994d | 322 | ->execute()->indexBy('name'); |
721c9da1 CW |
323 | foreach ($fields as $name => $field) { |
324 | $field['custom_field_id'] = $field['id']; | |
325 | $field['name'] = $groupName . '.' . $name; | |
84ad7693 | 326 | $field['entity'] = CustomGroupJoinable::getEntityFromExtends($field['custom_group_id.extends']); |
721c9da1 CW |
327 | $info[$name] = $field; |
328 | } | |
329 | \Civi::cache('metadata')->set($cacheKey, $info); | |
c9e3994d | 330 | } |
721c9da1 | 331 | return isset($info[$fieldName]) ? ['suffix' => $suffix] + $info[$fieldName] : NULL; |
c9e3994d CW |
332 | } |
333 | ||
076fe09a CW |
334 | /** |
335 | * Update weights when inserting or updating a sortable entity | |
336 | * @param array $record | |
337 | * @see SortableEntity | |
338 | */ | |
339 | protected function updateWeight(array &$record) { | |
340 | /** @var \CRM_Core_DAO|string $daoName */ | |
341 | $daoName = CoreUtil::getInfoItem($this->getEntityName(), 'dao'); | |
342 | $weightField = CoreUtil::getInfoItem($this->getEntityName(), 'order_by'); | |
343 | $idField = CoreUtil::getIdFieldName($this->getEntityName()); | |
344 | // If updating an existing record without changing weight, do nothing | |
345 | if (!isset($record[$weightField]) && !empty($record[$idField])) { | |
346 | return; | |
347 | } | |
348 | $daoFields = $daoName::getSupportedFields(); | |
349 | $newWeight = $record[$weightField] ?? NULL; | |
350 | $oldWeight = empty($record[$idField]) ? NULL : \CRM_Core_DAO::getFieldValue($daoName, $record[$idField], $weightField); | |
351 | ||
352 | // FIXME: Need a more metadata-ish approach. For now here's a hardcoded list of the fields sortable entities use for grouping. | |
9df894bb | 353 | $guesses = ['option_group_id', 'price_set_id', 'price_field_id', 'premiums_id', 'uf_group_id', 'custom_group_id', 'parent_id', 'domain_id']; |
076fe09a CW |
354 | $filters = []; |
355 | foreach (array_intersect($guesses, array_keys($daoFields)) as $filter) { | |
9df894bb | 356 | $filters[$filter] = $record[$filter] ?? (empty($record[$idField]) ? NULL : \CRM_Core_DAO::getFieldValue($daoName, $record[$idField], $filter)); |
076fe09a CW |
357 | } |
358 | // Supply default weight for new record | |
359 | if (!isset($record[$weightField]) && empty($record[$idField])) { | |
360 | $record[$weightField] = $this->getMaxWeight($daoName, $filters, $weightField); | |
361 | } | |
362 | else { | |
363 | $record[$weightField] = \CRM_Utils_Weight::updateOtherWeights($daoName, $oldWeight, $newWeight, $filters, $weightField); | |
364 | } | |
365 | } | |
366 | ||
367 | /** | |
368 | * Looks up max weight for a set of sortable entities | |
369 | * | |
370 | * Keeps it in memory in case this operation is writing more than one record | |
371 | * | |
372 | * @param $daoName | |
373 | * @param $filters | |
374 | * @param $weightField | |
375 | * @return int|mixed | |
376 | */ | |
377 | private function getMaxWeight($daoName, $filters, $weightField) { | |
378 | $key = $daoName . json_encode($filters); | |
379 | if (!isset($this->_maxWeights[$key])) { | |
380 | $this->_maxWeights[$key] = \CRM_Utils_Weight::getMax($daoName, $filters, $weightField) + 1; | |
381 | } | |
382 | else { | |
383 | ++$this->_maxWeights[$key]; | |
384 | } | |
385 | return $this->_maxWeights[$key]; | |
386 | } | |
387 | ||
19b53e5b | 388 | } |