Merge pull request #23419 from chrisgaraffa/contactheader-regions
[civicrm-core.git] / Civi / Api4 / Generic / ExportAction.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;
14
15 use Civi\API\Exception\NotImplementedException;
16 use Civi\Api4\Utils\CoreUtil;
17
18 /**
19 * Export $ENTITY to civicrm_managed format.
20 *
21 * This action generates an exportable array suitable for use in a .mgd.php file.
22 * The array will include any other entities that reference the $ENTITY.
23 *
24 * @method $this setId(int $id)
25 * @method int getId()
26 * @method $this setCleanup(string $cleanup)
27 * @method string getCleanup()
28 * @method $this setUpdate(string $update)
29 * @method string getUpdate()
30 */
31 class ExportAction extends AbstractAction {
32
33 /**
34 * Id of $ENTITY to export
35 * @var int
36 * @required
37 */
38 protected $id;
39
40 /**
41 * Specify rule for auto-updating managed entity
42 * @var string
43 * @options never,always,unmodified
44 */
45 protected $update = 'unmodified';
46
47 /**
48 * Specify rule for auto-deleting managed entity
49 * @var string
50 * @options never,always,unused
51 */
52 protected $cleanup = 'unused';
53
54 /**
55 * Used to prevent circular references
56 * @var array
57 */
58 private $exportedEntities = [];
59
60 /**
61 * @param \Civi\Api4\Generic\Result $result
62 */
63 public function _run(Result $result) {
64 $this->exportRecord($this->getEntityName(), $this->id, $result);
65 }
66
67 /**
68 * @param string $entityType
69 * @param int $entityId
70 * @param \Civi\Api4\Generic\Result $result
71 * @param string $parentName
72 * @param array $excludeFields
73 */
74 private function exportRecord(string $entityType, int $entityId, Result $result, $parentName = NULL, $excludeFields = []) {
75 if (isset($this->exportedEntities[$entityType][$entityId])) {
76 throw new \API_Exception("Circular reference detected: attempted to export $entityType id $entityId multiple times.");
77 }
78 $this->exportedEntities[$entityType][$entityId] = TRUE;
79 $select = $pseudofields = [];
80 $allFields = $this->getFieldsForExport($entityType, TRUE, $excludeFields);
81 foreach ($allFields as $field) {
82 // Use implicit join syntax but only if the fk entity has a `name` field
83 if (!empty($field['fk_entity']) && array_key_exists('name', $this->getFieldsForExport($field['fk_entity']))) {
84 $select[] = $field['name'] . '.name';
85 $pseudofields[$field['name'] . '.name'] = $field['name'];
86 }
87 // Use pseudoconstant syntax if appropriate
88 elseif ($this->shouldUsePseudoconstant($entityType, $field)) {
89 $select[] = $field['name'] . ':name';
90 $pseudofields[$field['name'] . ':name'] = $field['name'];
91 }
92 elseif (empty($field['fk_entity'])) {
93 $select[] = $field['name'];
94 }
95 // Needed for exporting the option group for a custom field
96 if ($entityType === 'CustomField' && ($field['fk_entity'] ?? NULL) === 'OptionGroup') {
97 $select[] = $field['name'];
98 }
99 }
100 $record = civicrm_api4($entityType, 'get', [
101 'checkPermissions' => $this->checkPermissions,
102 'select' => $select,
103 'where' => [['id', '=', $entityId]],
104 ])->first();
105 if (!$record) {
106 return;
107 }
108 // The get api always returns ID, but it should not be included in an export
109 unset($record['id']);
110 // Null fields should not use joins/pseudoconstants
111 foreach ($pseudofields as $alias => $fieldName) {
112 if (is_null($record[$alias])) {
113 unset($record[$alias]);
114 $record[$fieldName] = NULL;
115 }
116 }
117 // Should references be limited to the current domain?
118 $limitRefsByDomain = $entityType === 'OptionGroup' && \CRM_Core_OptionGroup::isDomainOptionGroup($record['name']) ? \CRM_Core_BAO_Domain::getDomain()->id : FALSE;
119 foreach ($allFields as $fieldName => $field) {
120 if (($field['fk_entity'] ?? NULL) === 'Domain') {
121 $alias = $fieldName . '.name';
122 if (isset($record[$alias])) {
123 // If this entity is for a specific domain, limit references to that same domain
124 if ($fieldName === 'domain_id') {
125 $limitRefsByDomain = \CRM_Core_DAO::getFieldValue('CRM_Core_DAO_Domain', $record[$alias], 'id', 'name');
126 }
127 // Swap current domain for special API keyword
128 if ($record[$alias] === \CRM_Core_BAO_Domain::getDomain()->name) {
129 unset($record[$alias]);
130 $record[$fieldName] = 'current_domain';
131 }
132 }
133 }
134 }
135 $name = ($parentName ?? '') . $entityType . '_' . ($record['name'] ?? count($this->exportedEntities[$entityType]));
136 // Ensure safe characters, max length
137 $name = \CRM_Utils_String::munge($name, '_', 127);
138 // Include option group with custom field
139 if ($entityType === 'CustomField') {
140 if (
141 !empty($record['option_group_id.name']) &&
142 // Sometimes fields share an option group; only export it once.
143 empty($this->exportedEntities['OptionGroup'][$record['option_group_id']])
144 ) {
145 $this->exportRecord('OptionGroup', $record['option_group_id'], $result);
146 }
147 unset($record['option_group_id']);
148 }
149 $result[] = [
150 'name' => $name,
151 'entity' => $entityType,
152 'cleanup' => $this->cleanup,
153 'update' => $this->update,
154 'params' => [
155 'version' => 4,
156 'values' => $record,
157 ],
158 ];
159 // Export entities that reference this one
160 $daoName = CoreUtil::getInfoItem($entityType, 'dao');
161 if ($daoName) {
162 /** @var \CRM_Core_DAO $dao */
163 $dao = new $daoName();
164 $dao->id = $entityId;
165 // Collect references into arrays keyed by entity type
166 $references = [];
167 foreach ($dao->findReferences() as $reference) {
168 $refEntity = \CRM_Utils_Array::first($reference::fields())['entity'] ?? '';
169 // Custom fields don't really "belong" to option groups despite the reference
170 if ($refEntity === 'CustomField' && $entityType === 'OptionGroup') {
171 continue;
172 }
173 // Limit references by domain
174 if (property_exists($reference, 'domain_id')) {
175 if (!isset($reference->domain_id)) {
176 $reference->find(TRUE);
177 }
178 if (isset($reference->domain_id) && $reference->domain_id != $limitRefsByDomain) {
179 continue;
180 }
181 }
182 $references[$refEntity][] = $reference;
183 }
184 foreach ($references as $refEntity => $records) {
185 $refApiType = CoreUtil::getInfoItem($refEntity, 'type') ?? [];
186 // Reference must be a ManagedEntity
187 if (!in_array('ManagedEntity', $refApiType, TRUE)) {
188 continue;
189 }
190 $exclude = [];
191 // For sortable entities, order by weight and exclude weight from the export (it will be auto-managed)
192 if (in_array('SortableEntity', $refApiType, TRUE)) {
193 $exclude[] = $weightCol = CoreUtil::getInfoItem($refEntity, 'order_by');
194 usort($records, function ($a, $b) use ($weightCol) {
195 if (!isset($a->$weightCol)) {
196 $a->find(TRUE);
197 }
198 if (!isset($b->$weightCol)) {
199 $b->find(TRUE);
200 }
201 return $a->$weightCol < $b->$weightCol ? -1 : 1;
202 });
203 }
204 foreach ($records as $record) {
205 $this->exportRecord($refEntity, $record->id, $result, $name . '_', $exclude);
206 }
207 }
208 }
209 }
210
211 /**
212 * If a field has a pseudoconstant list, determine whether it would be better
213 * to use pseudoconstant (field:name) syntax vs plain value.
214 *
215 * @param string $entityType
216 * @param array $field
217 * @return bool
218 */
219 private function shouldUsePseudoconstant(string $entityType, array $field) {
220 if (empty($field['options'])) {
221 return FALSE;
222 }
223 $daoName = CoreUtil::getInfoItem($entityType, 'dao');
224 // Options generated by a callback function tend to be stable,
225 // and the :name property may not be reliable. Use plain value.
226 if ($daoName && !empty($daoName::getSupportedFields()[$field['name']]['pseudoconstant']['callback'])) {
227 return FALSE;
228 }
229 // Options with numeric keys probably refer to auto-increment keys
230 // which vary across different databases. Use :name syntax.
231 $numericKeys = array_filter(array_keys($field['options']), 'is_numeric');
232 return count($numericKeys) === count($field['options']);
233 }
234
235 /**
236 * @param $entityType
237 * @param bool $loadOptions
238 * @param array $excludeFields
239 * @return array
240 */
241 private function getFieldsForExport($entityType, $loadOptions = FALSE, $excludeFields = []): array {
242 $conditions = [
243 ['type', 'IN', ['Field', 'Custom']],
244 ['readonly', '!=', TRUE],
245 ];
246 if ($excludeFields) {
247 $conditions[] = ['name', 'NOT IN', $excludeFields];
248 }
249 try {
250 return (array) civicrm_api4($entityType, 'getFields', [
251 'action' => 'create',
252 'where' => $conditions,
253 'loadOptions' => $loadOptions,
254 'checkPermissions' => $this->checkPermissions,
255 ])->indexBy('name');
256 }
257 catch (NotImplementedException $e) {
258 return [];
259 }
260 }
261
262 }