Commit | Line | Data |
---|---|---|
9287a0b7 | 1 | <?php |
2 | /* | |
3 | +--------------------------------------------------------------------+ | |
f452d72c | 4 | | Copyright CiviCRM LLC. All rights reserved. | |
9287a0b7 | 5 | | | |
f452d72c CW |
6 | | This work is published under the GNU AGPLv3 license with some | |
7 | | permitted exceptions and without any warranty. For full license | | |
8 | | and copyright information, see https://civicrm.org/licensing | | |
9287a0b7 | 9 | +--------------------------------------------------------------------+ |
10 | */ | |
11 | ||
12 | /** | |
9287a0b7 | 13 | * This class exists primarily for the purposes of supporting code clean up in the Merger class. |
14 | * | |
15 | * It is expected to be fast-moving and calling it outside the refactoring work is not advised. | |
f452d72c CW |
16 | * |
17 | * @package CRM | |
18 | * @copyright CiviCRM LLC https://civicrm.org/licensing | |
9287a0b7 | 19 | */ |
20 | class CRM_Dedupe_MergeHandler { | |
21 | ||
22 | /** | |
23 | * ID of contact to be kept. | |
24 | * | |
25 | * @var int | |
26 | */ | |
27 | protected $toKeepID; | |
28 | ||
29 | /** | |
30 | * ID of contact to be merged and deleted. | |
31 | * | |
32 | * @var int | |
33 | */ | |
34 | protected $toRemoveID; | |
35 | ||
4c54e0bd | 36 | /** |
37 | * Migration info array. | |
38 | * | |
39 | * This is a nasty bunch of data used in mysterious ways. We want to work to make it more | |
40 | * sensible but for now we store it. | |
41 | * | |
42 | * @var array | |
43 | */ | |
44 | protected $migrationInfo = []; | |
45 | ||
46 | /** | |
47 | * @return array | |
48 | */ | |
49 | public function getMigrationInfo(): array { | |
50 | return $this->migrationInfo; | |
51 | } | |
52 | ||
53 | /** | |
54 | * @param array $migrationInfo | |
55 | */ | |
56 | public function setMigrationInfo(array $migrationInfo) { | |
57 | $this->migrationInfo = $migrationInfo; | |
58 | } | |
59 | ||
9287a0b7 | 60 | /** |
61 | * @return mixed | |
62 | */ | |
63 | public function getToKeepID() { | |
64 | return $this->toKeepID; | |
65 | } | |
66 | ||
67 | /** | |
68 | * @param mixed $toKeepID | |
69 | */ | |
70 | public function setToKeepID($toKeepID) { | |
71 | $this->toKeepID = $toKeepID; | |
72 | } | |
73 | ||
74 | /** | |
75 | * @return mixed | |
76 | */ | |
77 | public function getToRemoveID() { | |
78 | return $this->toRemoveID; | |
79 | } | |
80 | ||
81 | /** | |
82 | * @param mixed $toRemoveID | |
83 | */ | |
84 | public function setToRemoveID($toRemoveID) { | |
85 | $this->toRemoveID = $toRemoveID; | |
86 | } | |
87 | ||
88 | /** | |
89 | * CRM_Dedupe_MergeHandler constructor. | |
90 | * | |
91 | * @param int $toKeepID | |
92 | * ID of contact to be kept. | |
93 | * @param int $toRemoveID | |
94 | * ID of contact to be removed. | |
95 | */ | |
96 | public function __construct(int $toKeepID, int $toRemoveID) { | |
97 | $this->setToKeepID($toKeepID); | |
98 | $this->setToRemoveID($toRemoveID); | |
99 | } | |
100 | ||
101 | /** | |
102 | * Get an array of tables that relate to the contact entity and will need consideration in a merge. | |
103 | * | |
104 | * The list of potential tables is filtered by tables which have data for the relevant contacts. | |
105 | */ | |
106 | public function getTablesRelatedToTheMergePair() { | |
107 | $relTables = CRM_Dedupe_Merger::relTables(); | |
108 | $activeRelTables = CRM_Dedupe_Merger::getActiveRelTables($this->toRemoveID); | |
109 | $activeMainRelTables = CRM_Dedupe_Merger::getActiveRelTables($this->toKeepID); | |
110 | foreach ($relTables as $name => $null) { | |
111 | if (!in_array($name, $activeRelTables, TRUE) && | |
112 | !(($name === 'rel_table_users') && in_array($name, $activeMainRelTables, TRUE)) | |
113 | ) { | |
114 | unset($relTables[$name]); | |
115 | } | |
116 | } | |
117 | return $relTables; | |
118 | } | |
119 | ||
eca28463 | 120 | /** |
121 | * Get an array of tables that have a dynamic reference to the contact table. | |
122 | * | |
123 | * This would be the case when the table uses entity_table + entity_id rather than an FK. | |
124 | * | |
125 | * There are a number of tables that 'could' but don't have contact related data so we | |
126 | * do a cached check for this, ensuring the query is only done once per batch run. | |
127 | * | |
128 | * @return array | |
129 | */ | |
130 | public function getTablesDynamicallyRelatedToContactTable() { | |
131 | if (!isset(\Civi::$statics[__CLASS__]['dynamic'])) { | |
132 | \Civi::$statics[__CLASS__]['dynamic'] = []; | |
9811efd4 | 133 | foreach (CRM_Core_DAO::getDynamicReferencesToTable('civicrm_contact') as $tableName => $fields) { |
d035857f | 134 | if ($tableName === 'civicrm_financial_item') { |
135 | // It turns out that civicrm_financial_item does not have an index on entity_table (only as the latter | |
136 | // part of a entity_id/entity_table index which probably is not adding any value over & above entity_id | |
137 | // only. This means this is a slow query. The correct fix is probably to add a whitelist to | |
138 | // values for entity_table in the schema. | |
139 | continue; | |
140 | } | |
9811efd4 JG |
141 | foreach ($fields as $field) { |
142 | $sql[] = "(SELECT '$tableName' as civicrm_table, '{$field[0]}' as field_name FROM $tableName WHERE {$field[1]} = 'civicrm_contact' LIMIT 1)"; | |
143 | } | |
eca28463 | 144 | } |
145 | $sqlString = implode(' UNION ', $sql); | |
146 | if ($sqlString) { | |
147 | $result = CRM_Core_DAO::executeQuery($sqlString); | |
148 | while ($result->fetch()) { | |
149 | \Civi::$statics[__CLASS__]['dynamic'][$result->civicrm_table] = $result->field_name; | |
150 | } | |
151 | } | |
152 | } | |
153 | return \Civi::$statics[__CLASS__]['dynamic']; | |
154 | } | |
155 | ||
4c54e0bd | 156 | /** |
157 | * Get the location blocks designated to be moved during the merge. | |
158 | * | |
159 | * Note this is a refactoring step and future refactors should develop a more coherent array | |
160 | * | |
161 | * @return array | |
162 | * The format is ['address' => [0 => ['is_replace' => TRUE]], 'email' => [0...],[1....] | |
163 | * where the entity is address, the internal indexing for the addresses on both contact is 1 | |
164 | * and the intent to replace the existing address is TRUE. | |
165 | */ | |
166 | public function getLocationBlocksToMerge(): array { | |
167 | $locBlocks = []; | |
168 | foreach ($this->getMigrationInfo() as $key => $value) { | |
169 | $isLocationField = (substr($key, 0, 14) === 'move_location_' and $value != NULL); | |
170 | if (!$isLocationField) { | |
171 | continue; | |
172 | } | |
173 | $locField = explode('_', $key); | |
174 | $fieldName = $locField[2]; | |
175 | $fieldCount = $locField[3]; | |
176 | ||
177 | // Set up the operation type (add/overwrite) | |
178 | // Ignore operation for websites | |
179 | // @todo Tidy this up | |
180 | $operation = 0; | |
181 | if ($fieldName !== 'website') { | |
182 | $operation = $this->getMigrationInfo()['location_blocks'][$fieldName][$fieldCount]['operation'] ?? NULL; | |
183 | } | |
184 | // default operation is overwrite. | |
185 | if (!$operation) { | |
186 | $operation = 2; | |
187 | } | |
188 | $locBlocks[$fieldName][$fieldCount]['is_replace'] = $operation === 2; | |
189 | } | |
190 | return $locBlocks; | |
191 | } | |
192 | ||
1e809f0e | 193 | /** |
194 | * Copy the data to be moved to a new DAO object. | |
195 | * | |
196 | * This is intended as a refactoring step - not the long term function. Do not | |
197 | * call from any function other than the one it is taken from (Merger::mergeLocations). | |
198 | * | |
1e809f0e | 199 | * @param int $otherBlockId |
200 | * @param string $name | |
201 | * @param int $blkCount | |
202 | * | |
99d57de1 | 203 | * @return CRM_Core_DAO_Address|CRM_Core_DAO_Email|CRM_Core_DAO_IM|CRM_Core_DAO_Phone|CRM_Core_DAO_Website |
204 | * | |
205 | * @throws \CRM_Core_Exception | |
1e809f0e | 206 | */ |
99d57de1 | 207 | public function copyDataToNewBlockDAO($otherBlockId, $name, $blkCount) { |
1e809f0e | 208 | // For the block which belongs to other-contact, link the location block to main-contact |
443b070f | 209 | $otherBlockDAO = $this->getDAOForLocationEntity($name, $this->getSelectedLocationType($name, $blkCount), $this->getSelectedType($name, $blkCount)); |
1e809f0e | 210 | $otherBlockDAO->contact_id = $this->getToKeepID(); |
1e809f0e | 211 | // Get the ID of this block on the 'other' contact, otherwise skip |
212 | $otherBlockDAO->id = $otherBlockId; | |
1e809f0e | 213 | return $otherBlockDAO; |
214 | } | |
215 | ||
2203317f | 216 | /** |
217 | * Get blocks, if any, to update for the deleted contact. | |
218 | * | |
219 | * If the deleted contact no longer has a primary address but still has | |
220 | * one or more blocks we want to ensure the remaining block is updated | |
221 | * to have is_primary = 1 in case the contact is ever undeleted. | |
222 | * | |
223 | * @param string $entity | |
224 | * | |
225 | * @return array | |
226 | * @throws \CRM_Core_Exception | |
227 | */ | |
228 | public function getBlocksToUpdateForDeletedContact($entity) { | |
229 | $movedBlocks = $this->getLocationBlocksToMerge()[$entity]; | |
230 | $deletedContactsBlocks = $this->getLocationBlocksForContactToRemove()[$entity]; | |
231 | $unMovedBlocks = array_values(array_diff_key($deletedContactsBlocks, $movedBlocks)); | |
232 | if (empty($unMovedBlocks) || empty($movedBlocks)) { | |
233 | return []; | |
234 | } | |
235 | foreach (array_keys($movedBlocks) as $index) { | |
236 | if ($deletedContactsBlocks[$index]['is_primary']) { | |
237 | // We have moved the primary - change any other block to be primary. | |
238 | $newPrimaryBlock = $this->getDAOForLocationEntity($entity); | |
239 | $newPrimaryBlock->id = $unMovedBlocks[0]['id']; | |
240 | $newPrimaryBlock->is_primary = 1; | |
241 | return [$newPrimaryBlock->id => $newPrimaryBlock]; | |
242 | } | |
243 | } | |
244 | return []; | |
245 | } | |
246 | ||
247 | /** | |
248 | * Get the details of the blocks to be transferred over for the given entity. | |
249 | * | |
250 | * @param string $entity | |
251 | * | |
252 | * @return array | |
253 | */ | |
254 | protected function getLocationBlocksToMoveForEntity($entity) { | |
255 | $movedBlocks = $this->getLocationBlocksToMerge()[$entity]; | |
256 | $blockDetails = $this->getLocationBlocksForContactToRemove()[$entity]; | |
257 | return array_intersect_key($blockDetails, $movedBlocks); | |
258 | } | |
259 | ||
260 | /** | |
261 | * Does the contact to keep have location blocks for the given entity. | |
262 | * | |
263 | * @param string $entity | |
264 | * | |
265 | * @return bool | |
266 | */ | |
267 | public function contactToKeepHasLocationBlocksForEntity($entity) { | |
268 | return !empty($this->getLocationBlocksForContactToKeep()[$entity]); | |
269 | } | |
270 | ||
271 | /** | |
272 | * Get the location blocks for the contact to be kept. | |
273 | * | |
274 | * @return array | |
275 | */ | |
276 | public function getLocationBlocksForContactToKeep() { | |
277 | return $this->getMigrationInfo()['main_details']['location_blocks']; | |
278 | } | |
279 | ||
280 | /** | |
281 | * Get the location blocks for the contact to be deleted. | |
282 | * | |
283 | * @return array | |
284 | */ | |
285 | public function getLocationBlocksForContactToRemove() { | |
286 | return $this->getMigrationInfo()['other_details']['location_blocks']; | |
287 | } | |
288 | ||
99d57de1 | 289 | /** |
290 | * Get the DAO object appropriate to the location entity. | |
291 | * | |
292 | * @param string $entity | |
293 | * | |
443b070f | 294 | * @param int|null $locationTypeID |
295 | * @param int|null $typeID | |
c3ea05e1 | 296 | * |
99d57de1 | 297 | * @return CRM_Core_DAO_Address|CRM_Core_DAO_Email|CRM_Core_DAO_IM|CRM_Core_DAO_Phone|CRM_Core_DAO_Website |
298 | * @throws \CRM_Core_Exception | |
299 | */ | |
443b070f | 300 | public function getDAOForLocationEntity($entity, $locationTypeID = NULL, $typeID = NULL) { |
99d57de1 | 301 | switch ($entity) { |
302 | case 'email': | |
c3ea05e1 | 303 | $dao = new CRM_Core_DAO_Email(); |
304 | $dao->location_type_id = $locationTypeID; | |
305 | return $dao; | |
99d57de1 | 306 | |
307 | case 'address': | |
c3ea05e1 | 308 | $dao = new CRM_Core_DAO_Address(); |
309 | $dao->location_type_id = $locationTypeID; | |
310 | return $dao; | |
99d57de1 | 311 | |
312 | case 'phone': | |
c3ea05e1 | 313 | $dao = new CRM_Core_DAO_Phone(); |
314 | $dao->location_type_id = $locationTypeID; | |
443b070f | 315 | $dao->phone_type_id = $typeID; |
c3ea05e1 | 316 | return $dao; |
99d57de1 | 317 | |
318 | case 'website': | |
443b070f | 319 | $dao = new CRM_Core_DAO_Website(); |
320 | $dao->website_type_id = $typeID; | |
321 | return $dao; | |
99d57de1 | 322 | |
323 | case 'im': | |
c3ea05e1 | 324 | $dao = new CRM_Core_DAO_IM(); |
325 | $dao->location_type_id = $locationTypeID; | |
326 | return $dao; | |
99d57de1 | 327 | |
328 | default: | |
329 | // Mostly here, along with the switch over a more concise format, to help IDEs understand the possibilities. | |
330 | throw new CRM_Core_Exception('Unsupported entity'); | |
331 | } | |
332 | } | |
333 | ||
c3ea05e1 | 334 | /** |
335 | * Get the selected location type for the given location block. | |
336 | * | |
337 | * This will retrieve any user selection if they specified which location to move a block to. | |
338 | * | |
339 | * @param string $entity | |
340 | * @param int $blockIndex | |
341 | * | |
342 | * @return int|null | |
343 | */ | |
344 | protected function getSelectedLocationType($entity, $blockIndex) { | |
345 | return $this->getMigrationInfo()['location_blocks'][$entity][$blockIndex]['locTypeId'] ?? NULL; | |
346 | } | |
347 | ||
443b070f | 348 | /** |
349 | * Get the selected type for the given location block. | |
350 | * | |
351 | * This will retrieve any user selection if they specified which type to move a block to (e.g 'Mobile' for phone). | |
352 | * | |
353 | * @param string $entity | |
354 | * @param int $blockIndex | |
355 | * | |
356 | * @return int|null | |
357 | */ | |
358 | protected function getSelectedType($entity, $blockIndex) { | |
359 | return $this->getMigrationInfo()['location_blocks'][$entity][$blockIndex]['typeTypeId'] ?? NULL; | |
360 | } | |
361 | ||
a5e9dbe9 | 362 | /** |
363 | * Merge location. | |
364 | * | |
365 | * Based on the data in the $locationMigrationInfo merge the locations for 2 contacts. | |
366 | * | |
367 | * The data is in the format received from the merge form (which is a fairly confusing format). | |
368 | * | |
369 | * It is converted into an array of DAOs which is passed to the alterLocationMergeData hook | |
370 | * before saving or deleting the DAOs. A new hook is added to allow these to be altered after they have | |
371 | * been calculated and before saving because | |
372 | * - the existing format & hook combo is so confusing it is hard for developers to change & inherently fragile | |
373 | * - passing to a hook right before save means calculations only have to be done once | |
374 | * - the existing pattern of passing dissimilar data to the same (merge) hook with a different 'type' is just | |
375 | * ugly. | |
376 | * | |
377 | * The use of the new hook is tested, including the fact it is called before contributions are merged, as this | |
378 | * is likely to be significant data in merge hooks. | |
379 | * | |
de7b2b20 | 380 | * @throws \API_Exception |
a5e9dbe9 | 381 | * @throws \CRM_Core_Exception |
382 | */ | |
383 | public function mergeLocations(): void { | |
a6f08a10 | 384 | $locBlocks = $this->getLocationBlocksToMerge(); |
a5e9dbe9 | 385 | $blocksDAO = []; |
a6f08a10 | 386 | $migrationInfo = $this->getMigrationInfo(); |
a5e9dbe9 | 387 | |
388 | // @todo Handle OpenID (not currently in API). | |
389 | if (!empty($locBlocks)) { | |
a5e9dbe9 | 390 | |
a6f08a10 | 391 | $primaryBlockIds = CRM_Contact_BAO_Contact::getLocBlockIds($this->getToKeepID(), ['is_primary' => 1]); |
392 | $billingBlockIds = CRM_Contact_BAO_Contact::getLocBlockIds($this->getToKeepID(), ['is_billing' => 1]); | |
a5e9dbe9 | 393 | |
394 | foreach ($locBlocks as $name => $block) { | |
395 | $blocksDAO[$name] = ['delete' => [], 'update' => []]; | |
a5e9dbe9 | 396 | $changePrimary = FALSE; |
397 | $primaryDAOId = (array_key_exists($name, $primaryBlockIds)) ? array_pop($primaryBlockIds[$name]) : NULL; | |
398 | $billingDAOId = (array_key_exists($name, $billingBlockIds)) ? array_pop($billingBlockIds[$name]) : NULL; | |
399 | ||
400 | foreach ($block as $blkCount => $values) { | |
401 | $otherBlockId = $migrationInfo['other_details']['location_blocks'][$name][$blkCount]['id'] ?? NULL; | |
402 | $mainBlockId = CRM_Utils_Array::value('mainContactBlockId', $migrationInfo['location_blocks'][$name][$blkCount], 0); | |
403 | if (!$otherBlockId) { | |
404 | continue; | |
405 | } | |
a6f08a10 | 406 | $otherBlockDAO = $this->copyDataToNewBlockDAO($otherBlockId, $name, $blkCount); |
a5e9dbe9 | 407 | |
408 | // If we're deliberately setting this as primary then add the flag | |
409 | // and remove it from the current primary location (if there is one). | |
410 | // But only once for each entity. | |
411 | $set_primary = $migrationInfo['location_blocks'][$name][$blkCount]['set_other_primary'] ?? NULL; | |
412 | if (!$changePrimary && $set_primary == "1") { | |
413 | $otherBlockDAO->is_primary = 1; | |
a5e9dbe9 | 414 | $changePrimary = TRUE; |
415 | } | |
416 | // Otherwise, if main contact already has primary, set it to 0. | |
417 | elseif ($primaryDAOId) { | |
418 | $otherBlockDAO->is_primary = 0; | |
419 | } | |
420 | ||
421 | // If the main contact already has a billing location, set this to 0. | |
422 | if ($billingDAOId) { | |
423 | $otherBlockDAO->is_billing = 0; | |
424 | } | |
425 | ||
426 | // overwrite - need to delete block which belongs to main-contact. | |
427 | if (!empty($mainBlockId) && $values['is_replace']) { | |
a6f08a10 | 428 | $deleteDAO = $this->getDAOForLocationEntity($name); |
a5e9dbe9 | 429 | $deleteDAO->id = $mainBlockId; |
430 | $deleteDAO->find(TRUE); | |
431 | ||
432 | // if we about to delete a primary / billing block, set the flags for new block | |
433 | // that we going to assign to main-contact | |
434 | if ($primaryDAOId && ($primaryDAOId == $deleteDAO->id)) { | |
435 | $otherBlockDAO->is_primary = 1; | |
436 | } | |
437 | if ($billingDAOId && ($billingDAOId == $deleteDAO->id)) { | |
438 | $otherBlockDAO->is_billing = 1; | |
439 | } | |
440 | $blocksDAO[$name]['delete'][$deleteDAO->id] = $deleteDAO; | |
441 | } | |
442 | $blocksDAO[$name]['update'][$otherBlockDAO->id] = $otherBlockDAO; | |
443 | } | |
a6f08a10 | 444 | $blocksDAO[$name]['update'] += $this->getBlocksToUpdateForDeletedContact($name); |
a5e9dbe9 | 445 | } |
446 | } | |
447 | ||
a6f08a10 | 448 | CRM_Utils_Hook::alterLocationMergeData($blocksDAO, $this->getToKeepID(), $this->getToRemoveID(), $migrationInfo); |
a5e9dbe9 | 449 | foreach ($blocksDAO as $blockDAOs) { |
450 | if (!empty($blockDAOs['update'])) { | |
451 | foreach ($blockDAOs['update'] as $blockDAO) { | |
de7b2b20 | 452 | $entity = CRM_Core_DAO_AllCoreTables::getBriefName(get_class($blockDAO)); |
453 | $values = ['checkPermissions' => FALSE]; | |
454 | foreach ($blockDAO->fields() as $field) { | |
455 | if (isset($blockDAO->{$field['name']})) { | |
456 | $values['values'][$field['name']] = $blockDAO->{$field['name']}; | |
457 | } | |
458 | } | |
459 | civicrm_api4($entity, 'update', $values); | |
a5e9dbe9 | 460 | } |
461 | } | |
462 | if (!empty($blockDAOs['delete'])) { | |
463 | foreach ($blockDAOs['delete'] as $blockDAO) { | |
de7b2b20 | 464 | $entity = CRM_Core_DAO_AllCoreTables::getBriefName(get_class($blockDAO)); |
465 | civicrm_api4($entity, 'delete', ['where' => [['id', '=', $blockDAO->id]], 'checkPermissions' => FALSE]); | |
a5e9dbe9 | 466 | } |
467 | } | |
468 | } | |
469 | } | |
470 | ||
9287a0b7 | 471 | } |