contact_id = $contact_id; $this->categories = ContactCategoryDefinition::get(FALSE) ->addOrderBy('execution_order') ->execute()->indexBy('id')->getArrayCopy(); // Check the last item is the 'default' one. if ((end($this->categories)['search_type'] ?? '') !== 'default') { throw new CRM_Core_Exception("ContactCategoryDefinition unconfigured; no default category."); } // Identify groups and searches used by definitions. $groupIDs = $searchIDs = []; foreach ($this->categories as $cat) { if ($cat['search_type'] === 'group') { $groupIDs[] = (int) $cat['search_data']['group_id']; } elseif ($cat['search_type'] === 'search') { $searchIDs[] = (int) $cat['search_data']['saved_search_id']; } } // Load group details; ensure all groups still exist. $this->groupDetails = []; if ($groupIDs) { $this->groupDetails = Group::get(FALSE) ->addWhere('id', 'IN', $groupIDs) ->execute()->indexBy('id')->getArrayCopy(); foreach ($this->categories as $cat) { if ($cat['search_type'] === 'group') { if (!isset($this->groupDetails[$cat['search_data']['group_id'] ?? 0])) { throw new CRM_Core_Exception("ContactCategoryDefinition $cat[id] $cat[label] references invalid Group: " . $cat['search_data']['group_id']); } } } } // Load all searches $this->searchDetails = []; if ($searchIDs) { $this->searchDetails = SavedSearch::get(FALSE) ->addWhere('id', 'IN', $searchIDs) ->execute()->indexBy('id')->getArrayCopy(); foreach ($this->categories as $cat) { if ($cat['search_type'] === 'search') { if (!isset($this->searchDetails[$cat['search_data']['saved_search_id'] ?? 0])) { throw new CRM_Core_Exception("ContactCategoryDefinition $cat[id] $cat[label] references invalid Search: " . $cat['search_data']['saved_search_id']); } $search = $this->searchDetails[$cat['search_data']['saved_search_id']]; if (!isset($search['api_params']) || !isset($search['api_entity'])) { throw new CRM_Core_Exception("ContactCategoryDefinition $cat[id] $cat[label] uses search $search[id] which does not look appropriate."); } } } } // TODO: check things like searches that group by contact_id } /** * Main calling point. */ public function run() { $this->refreshSmartGroups(); $summary = []; \CRM_Core_Transaction::create()->run(function($tx) use (&$summary) { $this->resetTable(); foreach ($this->categories as $cat) { try { if ($cat['search_type'] === 'group') { $this->assignCategoryFromGroup($cat); } elseif ($cat['search_type'] === 'search') { $this->assignCategoryFromSearch($cat); } elseif ($cat['search_type'] === 'default') { $this->assignDefaultCategory($cat); } else { throw new CRM_Core_Exception("Invalid search_type: $cat[search_type] for category $cat[id]"); } } catch (\Exception $e) { throw new CRM_Core_Exception( "ContactCats failed to run category $cat[id] $cat[search_type] $cat[label]: " . $e->getMessage(), 0, [], $e); } // future... } $summary = $this->createChangeActivities(); }); return $summary; } /** * Creates change activities and actually updates the current category data. */ protected function createChangeActivities() { $singleContactClause = $this->contact_id ? "AND id = $this->contact_id" : ""; Civi::log()->debug("Calculate changes", ['=' => 'timed', '=start' => "changes"]); $changes = CRM_Core_DAO::executeQuery(<< category_definition_id) $singleContactClause ORDER BY category_definition_id, next_category SQL); $lastChange = [0, 0]; $batch = []; $n = 0; $domainContactID = \CRM_Core_BAO_Domain::getDomain()->contact_id; $writeBatch = function() use (&$lastChange, &$batch, $domainContactID) { if (empty($batch)) { return; } $oldCategoryId = (int) ($lastChange[0] ?? 0); $newCategoryId = (int) ($lastChange[1] ?? 0); $oldName = $this->categories[$oldCategoryId]['label'] ?? E::ts('(Unknown)'); $subject = "$oldName → " . $this->categories[$newCategoryId]['label']; // Create activity on the first contact $a = Activity::create(FALSE) ->addValue('target_contact_id', $batch[0]) ->addValue('activity_type_id:name', 'changed_contact_category') ->addValue('source_contact_id', $domainContactID) ->addValue('status_id:name', 'Completed') ->addValue('subject', $subject) ->addValue('Category_changes.previous_category_id', $oldCategoryId) ->addValue('Category_changes.new_category_id', $newCategoryId) ->execute()->first(); $activityID = (int) $a['id']; // Join activity to all relevant contacts CRM_Core_DAO::executeQuery("INSERT INTO civicrm_activity_contact (contact_id, activity_id, record_type_id) SELECT id contact_id, $activityID activity_id, 3 record_type_id FROM civicrm_contact_category cc WHERE COALESCE(cc.category_definition_id, 0) = $oldCategoryId AND cc.next_category = $newCategoryId AND cc.id <> $batch[0] ")->free(); Civi::log()->debug(count($batch) . " changes: $subject"); }; while ($changes->fetch()) { $n++; if ($lastChange[0] !== $changes->category_definition_id || $lastChange[1] !== $changes->next_category) { $writeBatch(); $lastChange = [$changes->category_definition_id, $changes->next_category]; $batch = []; } $batch[] = $changes->id; } $changes->free(); $writeBatch(); Civi::log()->debug('Apply changes', ['=change' => 'applyChanges', '=timed' => 1]); CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category SET category_definition_id = next_category WHERE (category_definition_id IS NULL OR category_definition_id <> next_category) $singleContactClause")->free(); Civi::log()->debug('', ['=pop' => 1]); $singleContactClause = $this->contact_id ? "WHERE id = $this->contact_id" : ""; $summary = CRM_Core_DAO::executeQuery("SELECT next_category, count(*) from civicrm_contact_category $singleContactClause GROUP BY next_category")->fetchAll(); $summary['changes'] = $n; $_ = memory_get_peak_usage(TRUE); $summary['memory_use'] = @round($_ / pow(1024, ($i = floor(log($_, 1024)))), 2) . ' ' . ['b', 'kb', 'mb', 'gb', 'tb', 'pb'][$i]; return $summary; } protected function refreshSmartGroups() { $smartGroups = []; foreach ($this->groupDetails as $groupID => $group) { if (!empty($group['saved_search_id'])) { $smartGroups[] = $groupID; } } if ($smartGroups) { Civi::log()->debug('Refreshing smart groups', ['=' => 'timed,start', 'groups' => $smartGroups]); \CRM_Contact_BAO_GroupContactCache::loadAll($smartGroups); Civi::log()->debug('', ['=' => 'pop']); } } /** * Ensure we have one category row per not-trashed contact and zero the next_category field. */ protected function resetTable() { Civi::log()->debug('Resetting table', ['=' => 'start,timed']); $singleContactClause = $this->contact_id ? "WHERE cat.id = $this->contact_id" : ""; // Delete our data for deleted contacts. $dao = CRM_Core_DAO::executeQuery(<<N) { Civi::log()->debug("Deleted category data for {$dao->N} trashed contacts"); } $dao->free(); // Zero our internal next_category field. CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category cat SET next_category = 0 $singleContactClause;")->free(); Civi::log()->debug('stage 2'); // ensure we have all our contacts covered. // Q: is it quicker to do a WHERE NOT EXISTS? A: nope. $singleContactClause = $this->contact_id ? "AND id = $this->contact_id" : ""; CRM_Core_DAO::executeQuery(<<free(); Civi::log()->debug('Done resetting table', ['=' => 'pop']); } /** * */ protected function assignCategoryFromGroup(array $cat) { $groupID = (int) $cat['search_data']['group_id']; $group = $this->groupDetails[$groupID]; // Put unclaimed contacts in this group into this category. $isSmart = !empty($group['saved_search_id']); Civi::log()->debug($group['title'] . ($isSmart ? '(smart)' : ''), ['=' => 'timed', '=start' => "group$groupID"]); $table = $isSmart ? 'civicrm_group_contact_cache' : 'civicrm_group_contact'; $statusWhere = $isSmart ? '' : 'AND gc.status = "Added"'; $andContactIdCriterion = $this->contact_id ? "AND cc.id=$this->contact_id" : ''; $sql = <<free(); Civi::log()->debug('', ['=' => 'pop']); } /** * */ protected function assignCategoryFromSearch(array $cat) { $search = $this->searchDetails[$cat['search_data']['saved_search_id']]; Civi::log()->debug("Doing $cat[id] $cat[label]", ['=' => 'timed', '=start' => "ss" . $cat['search_data']['saved_search_id']]); $apiParams = $search['api_params']; if ($search['api_entity'] === 'Contact' && in_array('id', $apiParams['select'] ?? [])) { $contactIdKey = 'id'; if ($this->contact_id) { // Limit to one contact. $apiParams['where'][] = ['id', '=', $this->contact_id]; } } else { throw new CRM_Core_Exception("can't figure out contactID in search: " . json_encode($search)); } // We don't need them ordered. unset($apiParams['orderBy']); $apiParams['checkPermissions'] = FALSE; $contactIDs = civicrm_api4($search['api_entity'], 'get', $apiParams)->column($contactIdKey); // Unsure if this batching is needed. // It would be better if we could access the select SQL directly and do it all in an SQL update. $batchSize = 10000; while ($batch = array_splice($contactIDs, 0, $batchSize)) { $batch = implode(',', $batch); if (!preg_match('/^[0-9,]+$/', $batch)) { // Surely this can never happen. throw new CRM_Core_Exception("Erm, received non-integer contact IDs from Search $search[id]!"); } $sql = <<free(); } Civi::log()->debug('Done', ['=' => 'pop']); } protected function assignDefaultCategory(array $cat) { $id = (int) $cat['id']; CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category SET next_category = $id WHERE next_category = 0;")->free(); } }