3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
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 |
9 +--------------------------------------------------------------------+
13 * This class exists primarily for the purposes of supporting code clean up in the Merger class.
15 * It is expected to be fast-moving and calling it outside the refactoring work is not advised.
18 * @copyright CiviCRM LLC https://civicrm.org/licensing
20 class CRM_Dedupe_MergeHandler
{
23 * ID of contact to be kept.
30 * ID of contact to be merged and deleted.
34 protected $toRemoveID;
37 * Migration info array.
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.
44 protected $migrationInfo = [];
49 public function getMigrationInfo(): array {
50 return $this->migrationInfo
;
54 * @param array $migrationInfo
56 public function setMigrationInfo(array $migrationInfo) {
57 $this->migrationInfo
= $migrationInfo;
63 public function getToKeepID() {
64 return $this->toKeepID
;
68 * @param mixed $toKeepID
70 public function setToKeepID($toKeepID) {
71 $this->toKeepID
= $toKeepID;
77 public function getToRemoveID() {
78 return $this->toRemoveID
;
82 * @param mixed $toRemoveID
84 public function setToRemoveID($toRemoveID) {
85 $this->toRemoveID
= $toRemoveID;
89 * CRM_Dedupe_MergeHandler constructor.
91 * @param int $toKeepID
92 * ID of contact to be kept.
93 * @param int $toRemoveID
94 * ID of contact to be removed.
96 public function __construct(int $toKeepID, int $toRemoveID) {
97 $this->setToKeepID($toKeepID);
98 $this->setToRemoveID($toRemoveID);
102 * Get an array of tables that relate to the contact entity and will need consideration in a merge.
104 * The list of potential tables is filtered by tables which have data for the relevant contacts.
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))
114 unset($relTables[$name]);
121 * Get an array of tables that have a dynamic reference to the contact table.
123 * This would be the case when the table uses entity_table + entity_id rather than an FK.
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.
130 public function getTablesDynamicallyRelatedToContactTable() {
131 if (!isset(\Civi
::$statics[__CLASS__
]['dynamic'])) {
132 \Civi
::$statics[__CLASS__
]['dynamic'] = [];
133 foreach (CRM_Core_DAO
::getDynamicReferencesToTable('civicrm_contact') as $tableName => $fields) {
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.
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)";
145 $sqlString = implode(' UNION ', $sql);
147 $result = CRM_Core_DAO
::executeQuery($sqlString);
148 while ($result->fetch()) {
149 \Civi
::$statics[__CLASS__
]['dynamic'][$result->civicrm_table
] = $result->field_name
;
153 return \Civi
::$statics[__CLASS__
]['dynamic'];
157 * Get the location blocks designated to be moved during the merge.
159 * Note this is a refactoring step and future refactors should develop a more coherent 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.
166 public function getLocationBlocksToMerge(): array {
168 foreach ($this->getMigrationInfo() as $key => $value) {
169 $isLocationField = (substr($key, 0, 14) === 'move_location_' and $value != NULL);
170 if (!$isLocationField) {
173 $locField = explode('_', $key);
174 $fieldName = $locField[2];
175 $fieldCount = $locField[3];
177 // Set up the operation type (add/overwrite)
178 // Ignore operation for websites
179 // @todo Tidy this up
181 if ($fieldName !== 'website') {
182 $operation = $this->getMigrationInfo()['location_blocks'][$fieldName][$fieldCount]['operation'] ??
NULL;
184 // default operation is overwrite.
188 $locBlocks[$fieldName][$fieldCount]['is_replace'] = $operation === 2;
194 * Copy the data to be moved to a new DAO object.
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).
199 * @param int $otherBlockId
200 * @param string $name
201 * @param int $blkCount
203 * @return CRM_Core_DAO_Address|CRM_Core_DAO_Email|CRM_Core_DAO_IM|CRM_Core_DAO_Phone|CRM_Core_DAO_Website
205 * @throws \CRM_Core_Exception
207 public function copyDataToNewBlockDAO($otherBlockId, $name, $blkCount) {
208 // For the block which belongs to other-contact, link the location block to main-contact
209 $otherBlockDAO = $this->getDAOForLocationEntity($name, $this->getSelectedLocationType($name, $blkCount), $this->getSelectedType($name, $blkCount));
210 $otherBlockDAO->contact_id
= $this->getToKeepID();
211 // Get the ID of this block on the 'other' contact, otherwise skip
212 $otherBlockDAO->id
= $otherBlockId;
213 return $otherBlockDAO;
217 * Get blocks, if any, to update for the deleted contact.
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.
223 * @param string $entity
226 * @throws \CRM_Core_Exception
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)) {
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];
248 * Get the details of the blocks to be transferred over for the given entity.
250 * @param string $entity
254 protected function getLocationBlocksToMoveForEntity($entity) {
255 $movedBlocks = $this->getLocationBlocksToMerge()[$entity];
256 $blockDetails = $this->getLocationBlocksForContactToRemove()[$entity];
257 return array_intersect_key($blockDetails, $movedBlocks);
261 * Does the contact to keep have location blocks for the given entity.
263 * @param string $entity
267 public function contactToKeepHasLocationBlocksForEntity($entity) {
268 return !empty($this->getLocationBlocksForContactToKeep()[$entity]);
272 * Get the location blocks for the contact to be kept.
276 public function getLocationBlocksForContactToKeep() {
277 return $this->getMigrationInfo()['main_details']['location_blocks'];
281 * Get the location blocks for the contact to be deleted.
285 public function getLocationBlocksForContactToRemove() {
286 return $this->getMigrationInfo()['other_details']['location_blocks'];
290 * Get the DAO object appropriate to the location entity.
292 * @param string $entity
294 * @param int|null $locationTypeID
295 * @param int|null $typeID
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
300 public function getDAOForLocationEntity($entity, $locationTypeID = NULL, $typeID = NULL) {
303 $dao = new CRM_Core_DAO_Email();
304 $dao->location_type_id
= $locationTypeID;
308 $dao = new CRM_Core_DAO_Address();
309 $dao->location_type_id
= $locationTypeID;
313 $dao = new CRM_Core_DAO_Phone();
314 $dao->location_type_id
= $locationTypeID;
315 $dao->phone_type_id
= $typeID;
319 $dao = new CRM_Core_DAO_Website();
320 $dao->website_type_id
= $typeID;
324 $dao = new CRM_Core_DAO_IM();
325 $dao->location_type_id
= $locationTypeID;
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');
335 * Get the selected location type for the given location block.
337 * This will retrieve any user selection if they specified which location to move a block to.
339 * @param string $entity
340 * @param int $blockIndex
344 protected function getSelectedLocationType($entity, $blockIndex) {
345 return $this->getMigrationInfo()['location_blocks'][$entity][$blockIndex]['locTypeId'] ??
NULL;
349 * Get the selected type for the given location block.
351 * This will retrieve any user selection if they specified which type to move a block to (e.g 'Mobile' for phone).
353 * @param string $entity
354 * @param int $blockIndex
358 protected function getSelectedType($entity, $blockIndex) {
359 return $this->getMigrationInfo()['location_blocks'][$entity][$blockIndex]['typeTypeId'] ??
NULL;
365 * Based on the data in the $locationMigrationInfo merge the locations for 2 contacts.
367 * The data is in the format received from the merge form (which is a fairly confusing format).
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
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.
380 * @throws \API_Exception
381 * @throws \CRM_Core_Exception
383 public function mergeLocations(): void
{
384 $locBlocks = $this->getLocationBlocksToMerge();
386 $migrationInfo = $this->getMigrationInfo();
388 // @todo Handle OpenID (not currently in API).
389 if (!empty($locBlocks)) {
391 $primaryBlockIds = CRM_Contact_BAO_Contact
::getLocBlockIds($this->getToKeepID(), ['is_primary' => 1]);
392 $billingBlockIds = CRM_Contact_BAO_Contact
::getLocBlockIds($this->getToKeepID(), ['is_billing' => 1]);
394 foreach ($locBlocks as $name => $block) {
395 $blocksDAO[$name] = ['delete' => [], 'update' => []];
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;
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) {
406 $otherBlockDAO = $this->copyDataToNewBlockDAO($otherBlockId, $name, $blkCount);
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;
414 $changePrimary = TRUE;
416 // Otherwise, if main contact already has primary, set it to 0.
417 elseif ($primaryDAOId) {
418 $otherBlockDAO->is_primary
= 0;
421 // If the main contact already has a billing location, set this to 0.
423 $otherBlockDAO->is_billing
= 0;
426 // overwrite - need to delete block which belongs to main-contact.
427 if (!empty($mainBlockId) && $values['is_replace']) {
428 $deleteDAO = $this->getDAOForLocationEntity($name);
429 $deleteDAO->id
= $mainBlockId;
430 $deleteDAO->find(TRUE);
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;
437 if ($billingDAOId && ($billingDAOId == $deleteDAO->id
)) {
438 $otherBlockDAO->is_billing
= 1;
440 $blocksDAO[$name]['delete'][$deleteDAO->id
] = $deleteDAO;
442 $blocksDAO[$name]['update'][$otherBlockDAO->id
] = $otherBlockDAO;
444 $blocksDAO[$name]['update'] +
= $this->getBlocksToUpdateForDeletedContact($name);
448 CRM_Utils_Hook
::alterLocationMergeData($blocksDAO, $this->getToKeepID(), $this->getToRemoveID(), $migrationInfo);
449 foreach ($blocksDAO as $blockDAOs) {
450 if (!empty($blockDAOs['update'])) {
451 foreach ($blockDAOs['update'] as $blockDAO) {
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']};
459 civicrm_api4($entity, 'update', $values);
462 if (!empty($blockDAOs['delete'])) {
463 foreach ($blockDAOs['delete'] as $blockDAO) {
464 $entity = CRM_Core_DAO_AllCoreTables
::getBriefName(get_class($blockDAO));
465 civicrm_api4($entity, 'delete', ['where' => [['id', '=', $blockDAO->id
]], 'checkPermissions' => FALSE]);