* Group id.
* @param string $mode
* Helps decide how to behave when there are conflicts.
- * A 'safe' value skips the merge if there are any un-resolved conflicts.
- * Does a force merge otherwise.
+ * A 'safe' value skips the merge if there are any un-resolved conflicts, wheras 'aggressive'
+ * mode does a force merge.
* @param bool $autoFlip to let api decide which contact to retain and which to delete.
- * Wether to let api decide which contact to retain and which to delete.
+ * Whether to let api decide which contact to retain and which to delete.
* @param int $batchLimit number of merges to carry out in one batch.
* @param int $isSelected if records with is_selected column needs to be processed.
*
+ * @param array $criteria
+ * Criteria to use in the filter.
+ *
+ * @param bool $checkPermissions
+ * Respect logged in user permissions.
+ *
* @return array|bool
*/
- public static function batchMerge($rgid, $gid = NULL, $mode = 'safe', $autoFlip = TRUE, $batchLimit = 1, $isSelected = 2) {
- $cacheKeyString = CRM_Dedupe_Merger::getMergeCacheKeyString($rgid, $gid);
- $join = CRM_Dedupe_Merger::getJoinOnDedupeTable();
-
- $where = "de.id IS NULL";
- if ($isSelected === 0 || $isSelected === 1) {
- $where .= " AND pn.is_selected = {$isSelected}";
- }// else consider all dupe pairs
- $where .= " LIMIT {$batchLimit}";
-
+ public static function batchMerge($rgid, $gid = NULL, $mode = 'safe', $autoFlip = TRUE, $batchLimit = 1, $isSelected = 2, $criteria = array(), $checkPermissions = TRUE) {
$redirectForPerformance = ($batchLimit > 1) ? TRUE : FALSE;
-
- $dupePairs = CRM_Core_BAO_PrevNextCache::retrieve($cacheKeyString, $join, $where);
- if (empty($dupePairs) && !$redirectForPerformance && $isSelected == 2) {
- // If we haven't found any dupes, probably cache is empty.
- // Try filling cache and give another try.
- CRM_Core_BAO_PrevNextCache::refillCache($rgid, $gid, $cacheKeyString);
- $dupePairs = CRM_Core_BAO_PrevNextCache::retrieve($cacheKeyString, $join, $where);
- }
+ $reloadCacheIfEmpty = (!$redirectForPerformance && $isSelected == 2);
+ $dupePairs = self::getDuplicatePairs($rgid, $gid, $reloadCacheIfEmpty, $batchLimit, $isSelected, '', ($mode == 'aggressive'), $criteria, $checkPermissions);
$cacheParams = array(
- 'cache_key_string' => $cacheKeyString,
- 'join' => $join,
- 'where' => $where,
+ 'cache_key_string' => self::getMergeCacheKeyString($rgid, $gid, $criteria, $checkPermissions),
+ // @todo stop passing these parameters in & instead calculate them in the merge function based
+ // on the 'real' params like $isRespectExclusions $batchLimit and $isSelected.
+ 'join' => self::getJoinOnDedupeTable(),
+ 'where' => self::getWhereString($batchLimit, $isSelected),
);
- return CRM_Dedupe_Merger::merge($dupePairs, $cacheParams, $mode, $autoFlip, $redirectForPerformance);
+ return CRM_Dedupe_Merger::merge($dupePairs, $cacheParams, $mode, $autoFlip, $redirectForPerformance, $checkPermissions);
}
/**
";
}
+ /**
+ * Get where string for dedupe join.
+ *
+ * @param int $batchLimit
+ * @param bool $isSelected
+ *
+ * @return string
+ */
+ protected static function getWhereString($batchLimit, $isSelected) {
+ $where = "de.id IS NULL";
+ if ($isSelected === 0 || $isSelected === 1) {
+ $where .= " AND pn.is_selected = {$isSelected}";
+ }
+ // else consider all dupe pairs
+ // @todo Adding limit to Where??!!
+ $where .= " LIMIT {$batchLimit}";
+ return $where;
+ }
+
public static function updateMergeStats($cacheKeyString, $result = array()) {
// gather latest stats
$merged = count($result['merged']);
CRM_Core_BAO_PrevNextCache::setItem($values);
}
+ /**
+ * Delete information about merges for the given string.
+ *
+ * @param $cacheKeyString
+ */
public static function resetMergeStats($cacheKeyString) {
- return CRM_Core_BAO_PrevNextCache::deleteItem(NULL, "{$cacheKeyString}_stats");
+ CRM_Core_BAO_PrevNextCache::deleteItem(NULL, "{$cacheKeyString}_stats");
}
public static function getMergeStats($cacheKeyString) {
* A 'safe' value skips the merge if there are any un-resolved conflicts.
* Does a force merge otherwise (aggressive mode).
* @param bool $autoFlip to let api decide which contact to retain and which to delete.
- * Wether to let api decide which contact to retain and which to delete.
- *
+ * Whether to let api decide which contact to retain and which to delete.
*
* @param bool $redirectForPerformance
+ * Redirect to a url for batch processing.
+ *
+ * @param bool $checkPermissions
+ * Respect logged in user permissions.
*
* @return array|bool
*/
public static function merge($dupePairs = array(), $cacheParams = array(), $mode = 'safe',
- $autoFlip = TRUE, $redirectForPerformance = FALSE
+ $autoFlip = TRUE, $redirectForPerformance = FALSE, $checkPermissions = TRUE
) {
$cacheKeyString = CRM_Utils_Array::value('cache_key_string', $cacheParams);
$resultStats = array('merged' => array(), 'skipped' => array());
// doNotResetCache flag
$config = CRM_Core_Config::singleton();
$config->doNotResetCache = 1;
+ $deletedContacts = array();
while (!empty($dupePairs)) {
- foreach ($dupePairs as $dupes) {
+ foreach ($dupePairs as $index => $dupes) {
+ if (in_array($dupes['dstID'], $deletedContacts) || in_array($dupes['srcID'], $deletedContacts)) {
+ unset($dupePairs[$index]);
+ continue;
+ }
CRM_Utils_Hook::merge('flip', $dupes, $dupes['dstID'], $dupes['srcID']);
$mainId = $dupes['dstID'];
$otherId = $dupes['srcID'];
// Generate var $migrationInfo. The variable structure is exactly same as
// $formValues submitted during a UI merge for a pair of contacts.
- $rowsElementsAndInfo = CRM_Dedupe_Merger::getRowsElementsAndInfo($mainId, $otherId);
+ $rowsElementsAndInfo = CRM_Dedupe_Merger::getRowsElementsAndInfo($mainId, $otherId, $checkPermissions);
$migrationInfo = &$rowsElementsAndInfo['migration_info'];
// go ahead with merge if there is no conflict
$conflicts = array();
if (!CRM_Dedupe_Merger::skipMerge($mainId, $otherId, $migrationInfo, $mode, $conflicts)) {
- CRM_Dedupe_Merger::moveAllBelongings($mainId, $otherId, $migrationInfo);
+ CRM_Dedupe_Merger::moveAllBelongings($mainId, $otherId, $migrationInfo, $checkPermissions);
$resultStats['merged'][] = array('main_id' => $mainId, 'other_id' => $otherId);
+ $deletedContacts[] = $otherId;
}
else {
$resultStats['skipped'][] = array('main_id' => $mainId, 'other_id' => $otherId);
if ($cacheKeyString && !$redirectForPerformance) {
// retrieve next pair of dupes
+ // @todo call getDuplicatePairs.
$dupePairs = CRM_Core_BAO_PrevNextCache::retrieve($cacheKeyString,
$cacheParams['join'],
- $cacheParams['where']
+ $cacheParams['where'],
+ 0,
+ 0,
+ array(),
+ '',
+ FALSE
);
}
else {
*/
public static function skipMerge($mainId, $otherId, &$migrationInfo, $mode = 'safe', &$conflicts = array()) {
- $migrationData = array(
- 'old_migration_info' => $migrationInfo,
- 'mode' => $mode,
- );
-
+ $originalMigrationInfo = $migrationInfo;
foreach ($migrationInfo as $key => $val) {
if ($val === "null") {
// Rule: Never overwrite with an empty value (in any mode)
// Rule: If both main-contact, and other-contact have a field with a
// different value, then let $mode decide if to merge it or not
if (
- !empty($migrationInfo['rows'][$key]['main'])
+ (!empty($migrationInfo['rows'][$key]['main'])
+ // For custom fields a 0 (e.g in an int field) could be a true conflict. This
+ // is probably true for other fields too - e.g. 'do_not_email' but
+ // leaving that investigation as a @todo - until tests can be written.
+ // Note the handling of this has test coverage - although the data-typing
+ // of '0' feels flakey we have insurance.
+ || ($migrationInfo['rows'][$key]['main'] === '0' && substr($key, 0, 12) == 'move_custom_')
+ )
&& $migrationInfo['rows'][$key]['main'] != $migrationInfo['rows'][$key]['other']
) {
// Rule: Catch address conflicts (same address type on both contacts)
if ($fieldName == 'address') {
- $mainNewLocTypeId = $migrationInfo['location_blocks'][$fieldName][$fieldCount]['locTypeId'];
if (
isset($migrationInfo['main_details']['location_blocks']['address']) &&
!empty($migrationInfo['main_details']['location_blocks']['address'])
) {
+ $otherAddresses = $migrationInfo['other_details']['location_blocks']['address'];
+ $otherAddressLookup = array();
+ foreach ($otherAddresses as $otherAddressIndex => $otherAddress) {
+ $otherAddressLookup[$otherAddress['location_type_id']] = $otherAddressIndex;
+ }
// Look for this LocTypeId in the results
// @todo This can be streamlined using array_column() in PHP 5.5+
foreach ($migrationInfo['main_details']['location_blocks']['address'] as $addressKey => $addressRecord) {
- if ($addressRecord['location_type_id'] == $mainNewLocTypeId) {
+ $otherAddressIndex = CRM_Utils_Array::value($addressRecord['location_type_id'], $otherAddressLookup);
+ $hasMatchingAddress = FALSE;
+ foreach ($otherAddresses as $otherAddress) {
+ if (self::addressIsSame($addressRecord, $otherAddress)) {
+ $hasMatchingAddress = TRUE;
+ }
+ }
+ if ($hasMatchingAddress) {
+ unset($migrationInfo[$key]);
+ }
+ elseif (!empty($otherAddresses[$otherAddressIndex]) && !self::addressIsSame($addressRecord, $otherAddresses[$otherAddressIndex])) {
$conflicts[$key] = NULL;
break;
}
}
-
}
}
// For other locations, don't merge/add if the values are the same
// there's a conflict (to handle "gotchas"). fields_in_conflict could be modified here
// merge happens with new values filled in here. For a particular field / row not to be merged
// field should be unset from fields_in_conflict.
- $migrationData['fields_in_conflict'] = $conflicts;
- $migrationData['merge_mode'] = $mode;
+ $migrationData = array(
+ 'old_migration_info' => $originalMigrationInfo,
+ 'mode' => $mode,
+ 'fields_in_conflict' => $conflicts,
+ 'merge_mode' => $mode,
+ 'migration_info' => $migrationInfo,
+ );
CRM_Utils_Hook::merge('batch', $migrationData, $mainId, $otherId);
$conflicts = $migrationData['fields_in_conflict'];
// allow hook to override / manipulate migrationInfo as well
- $migrationInfo = $migrationData['old_migration_info'];
+ $migrationInfo = $migrationData['migration_info'];
if (!empty($conflicts)) {
foreach ($conflicts as $key => $val) {
return FALSE;
}
+ /**
+ * Compare 2 addresses to see if they are the same.
+ *
+ * @param array $mainAddress
+ * @param array $comparisonAddress
+ *
+ * @return bool
+ */
+ static protected function addressIsSame($mainAddress, $comparisonAddress) {
+ $keysToIgnore = array('id', 'is_primary', 'is_billing', 'manual_geo_code', 'contact_id');
+ foreach ($comparisonAddress as $field => $value) {
+ if (in_array($field, $keysToIgnore)) {
+ continue;
+ }
+ if (!empty($value) && $mainAddress[$field] != $value) {
+ return FALSE;
+ }
+ }
+ return TRUE;
+ }
+
/**
* A function to build an array of information about location blocks that is
* required when merging location fields
* Main contact with whom merge has to happen.
* @param int $otherId
* Duplicate contact which would be deleted after merge operation.
+ * @param bool $checkPermissions
+ * Should the logged in user's permissions be ignore. Setting this to false is
+ * highly risky as it could cause data to be lost due to conflicts not showing up.
+ * OTOH there is a risk a merger might view custom data they do not have permission to.
+ * Hence for now only making this really explicit and making it reflect perms in
+ * an api call.
+ *
+ * @todo review permissions issue!
*
* @return array|bool|int
*
* to move all fields from the 'other' contact to the 'main' contact, as
* though the form had been submitted with those options.
*
+ * @throws \CRM_Core_Exception
+ * @throws \CiviCRM_API3_Exception
+ * @throws \Exception
*/
- public static function getRowsElementsAndInfo($mainId, $otherId) {
+ public static function getRowsElementsAndInfo($mainId, $otherId, $checkPermissions = TRUE) {
$qfZeroBug = 'e8cddb72-a257-11dc-b9cc-0016d3330ee9';
// Fetch contacts
// CRM-18480: Cancel the process if the contact is already deleted
if (isset($result['values'][$cid]['contact_is_deleted']) && !empty($result['values'][$cid]['contact_is_deleted'])) {
- CRM_Core_Error::fatal(ts('Cannot merge because the \'%1\' contact (ID %2) has been deleted.', array(1 => $moniker, 2 => $cid)));
+ throw new CRM_Core_Exception(ts('Cannot merge because the \'%1\' contact (ID %2) has been deleted.', array(1 => $moniker, 2 => $cid)));
}
$$moniker = $result['values'][$cid];
foreach ($values['values'] as $index => $value) {
$locations[$moniker][$blockName][$cnt] = $value;
// Fix address display
- $display = '';
if ($blockName == 'address') {
CRM_Core_BAO_Address::fixAddress($value);
- $display = CRM_Utils_Address::format($value);
- $locations[$moniker][$blockName][$cnt]['display'] = $display;
+ $locations[$moniker][$blockName][$cnt]['display'] = CRM_Utils_Address::format($value);
}
$cnt++;
}
// handle custom fields
- $mainTree = CRM_Core_BAO_CustomGroup::getTree($main['contact_type'], CRM_Core_DAO::$_nullObject, $mainId, -1,
- CRM_Utils_Array::value('contact_sub_type', $main)
+ $mainTree = CRM_Core_BAO_CustomGroup::getTree($main['contact_type'], NULL, $mainId, -1,
+ CRM_Utils_Array::value('contact_sub_type', $main), NULL, TRUE, NULL, TRUE, $checkPermissions
);
$otherTree = CRM_Core_BAO_CustomGroup::getTree($main['contact_type'], CRM_Core_DAO::$_nullObject, $otherId, -1,
- CRM_Utils_Array::value('contact_sub_type', $other)
+ CRM_Utils_Array::value('contact_sub_type', $other), NULL, TRUE, NULL, TRUE, $checkPermissions
);
CRM_Core_DAO::freeResult();
*
* @param $migrationInfo
*
+ * @param bool $checkPermissions
+ * Respect logged in user permissions.
+ *
* @return bool
*/
- public static function moveAllBelongings($mainId, $otherId, $migrationInfo) {
+ public static function moveAllBelongings($mainId, $otherId, $migrationInfo, $checkPermissions = TRUE) {
if (empty($migrationInfo)) {
return FALSE;
}
$submitted[$key] = CRM_Core_BAO_CustomField::displayValue($value, $fid);
break;
+ case 'Select Date':
+ if ($cFields[$fid]['attributes']['is_view']) {
+ $submitted[$key] = date('YmdHis', strtotime($submitted[$key]));
+ }
+ break;
+
case 'CheckBox':
case 'AdvMulti-Select':
case 'Multi-Select':
CRM_Core_BAO_CustomValueTable::setValues($viewOnlyCustomFields);
}
- if (CRM_Core_Permission::check('merge duplicate contacts') &&
- CRM_Core_Permission::check('delete contacts')
+ if (!$checkPermissions || (CRM_Core_Permission::check('merge duplicate contacts') &&
+ CRM_Core_Permission::check('delete contacts'))
) {
// if ext id is submitted then set it null for contact to be deleted
if (!empty($submitted['external_identifier'])) {
$query = "UPDATE civicrm_contact SET external_identifier = null WHERE id = {$otherId}";
CRM_Core_DAO::executeQuery($query);
}
-
civicrm_api3('contact', 'delete', array('id' => $otherId));
- CRM_Core_BAO_PrevNextCache::deleteItem($otherId);
}
- // FIXME: else part
- // else {
- // CRM_Core_Session::setStatus( ts('Do not have sufficient permission to delete duplicate contact.') );
- // }
// CRM-15681 merge sub_types
if ($other_sub_types = CRM_Utils_array::value('contact_sub_type', $migrationInfo['other_details'])) {
}
}
+ /**
+ * Get Duplicate Pairs based on a rule for a group.
+ *
+ * @param int $rule_group_id
+ * @param int $group_id
+ * @param bool $reloadCacheIfEmpty
+ * @param int $batchLimit
+ * @param bool $isSelected
+ * @param array|string $orderByClause
+ * @param bool $includeConflicts
+ * @param array $criteria
+ * Additional criteria to narrow down the merge group.
+ *
+ * @param bool $checkPermissions
+ * Respect logged in user permissions.
+ *
+ * @return array
+ * Array of matches meeting the criteria.
+ */
+ public static function getDuplicatePairs($rule_group_id, $group_id, $reloadCacheIfEmpty, $batchLimit, $isSelected, $orderByClause = '', $includeConflicts = TRUE, $criteria = array(), $checkPermissions = TRUE) {
+ $where = self::getWhereString($batchLimit, $isSelected);
+ $cacheKeyString = self::getMergeCacheKeyString($rule_group_id, $group_id, $criteria, $checkPermissions);
+ $join = self::getJoinOnDedupeTable();
+ $dupePairs = CRM_Core_BAO_PrevNextCache::retrieve($cacheKeyString, $join, $where, 0, 0, array(), $orderByClause, $includeConflicts);
+ if (empty($dupePairs) && $reloadCacheIfEmpty) {
+ // If we haven't found any dupes, probably cache is empty.
+ // Try filling cache and give another try. We don't need to specify include conflicts here are there will not be any
+ // until we have done some processing.
+ CRM_Core_BAO_PrevNextCache::refillCache($rule_group_id, $group_id, $cacheKeyString, $criteria, $checkPermissions);
+ $dupePairs = CRM_Core_BAO_PrevNextCache::retrieve($cacheKeyString, $join, $where, 0, 0, array(), $orderByClause, $includeConflicts);
+ return $dupePairs;
+ }
+ return $dupePairs;
+ }
+
/**
* Get the cache key string for the merge action.
*
* @param int $rule_group_id
* @param int $group_id
+ * @param array $criteria
+ * Additional criteria to narrow down the merge group.
+ * Currently we are only supporting the key 'contact' within it.
+ *
+ * @param bool $checkPermissions
+ * Respect the users permissions.
*
* @return string
*/
- public static function getMergeCacheKeyString($rule_group_id, $group_id) {
+ public static function getMergeCacheKeyString($rule_group_id, $group_id, $criteria = array(), $checkPermissions = TRUE) {
$contactType = CRM_Dedupe_BAO_RuleGroup::getContactTypeForRuleGroup($rule_group_id);
$cacheKeyString = "merge {$contactType}";
$cacheKeyString .= $rule_group_id ? "_{$rule_group_id}" : '_0';
$cacheKeyString .= $group_id ? "_{$group_id}" : '_0';
+ $cacheKeyString .= !empty($criteria) ? serialize($criteria) : '_0';
+ if ($checkPermissions) {
+ $contactID = CRM_Core_Session::getLoggedInContactID();
+ if (!$contactID) {
+ // Distinguish between no permission check & no logged in user.
+ $contactID = 'null';
+ }
+ $cacheKeyString .= '_' . $contactID;
+ }
+ else {
+ $cacheKeyString .= '_0';
+ }
return $cacheKeyString;
}