diff --git a/CRM/Contact/BAO/GroupContactCache.php b/CRM/Contact/BAO/GroupContactCache.php index 02f1dce5b7d0..8fc361e64ba3 100644 --- a/CRM/Contact/BAO/GroupContactCache.php +++ b/CRM/Contact/BAO/GroupContactCache.php @@ -336,11 +336,12 @@ public static function removeContact($cid, $groupId = NULL) { /** * Load the smart group cache for a saved search. * - * @param object $group + * @param CRM_Core_DAO $group * The smart group that needs to be loaded. * @param bool $force * deprecated parameter = Should we force a search through. * + * return bool * @throws \CRM_Core_Exception */ public static function load($group, $force = FALSE) { @@ -361,6 +362,7 @@ public static function load($group, $force = FALSE) { self::releaseGroupLocks([$groupID]); $groupContactsTempTable->drop(); } + return in_array($groupID, $lockedGroups); } /** @@ -482,14 +484,15 @@ public static function getCacheInvalidDateTime(): string { } /** - * Invalidates the smart group cache for a particular group - * @param int $groupID - Group to invalidate + * Invalidates the smart group cache for one or more groups + * @param int|int[] $groupID - Group to invalidate */ public static function invalidateGroupContactCache($groupID): void { + $groupIDs = implode(',', (array) $groupID); CRM_Core_DAO::executeQuery('UPDATE civicrm_group SET cache_date = NULL - WHERE id = %1 AND (saved_search_id IS NOT NULL OR children IS NOT NULL)', [ - 1 => [$groupID, 'Positive'], + WHERE id IN (%1) AND (saved_search_id IS NOT NULL OR children IS NOT NULL)', [ + 1 => [$groupIDs, 'CommaSeparatedIntegers'], ]); } diff --git a/Civi/Api4/Action/Group/Refresh.php b/Civi/Api4/Action/Group/Refresh.php new file mode 100644 index 000000000000..ce4262be9695 --- /dev/null +++ b/Civi/Api4/Action/Group/Refresh.php @@ -0,0 +1,35 @@ +id = $item['id']; + if (\CRM_Contact_BAO_GroupContactCache::load($group)) { + $result[] = $item; + } + } + } + +} diff --git a/Civi/Api4/Group.php b/Civi/Api4/Group.php index 67a34edeef79..0a5d2e0ca606 100644 --- a/Civi/Api4/Group.php +++ b/Civi/Api4/Group.php @@ -22,6 +22,16 @@ class Group extends Generic\DAOEntity { use Generic\Traits\ManagedEntity; + /** + * @param bool $checkPermissions + * @return \Civi\Api4\Action\Group\Refresh + * @throws \CRM_Core_Exception + */ + public static function refresh(bool $checkPermissions = TRUE): Action\Group\Refresh { + return (new Action\Group\Refresh(__CLASS__, __FUNCTION__)) + ->setCheckPermissions($checkPermissions); + } + /** * Provides more-open permissions that will be further restricted by checkAccess * @@ -34,6 +44,7 @@ public static function permissions():array { return [ // Create permission depends on the group type (see CRM_Contact_BAO_Group::_checkAccess). 'create' => ['access CiviCRM', ['edit groups', 'access CiviMail', 'create mailings']], + 'refresh' => ['access CiviCRM'], ] + $permissions; } diff --git a/Civi/Api4/Service/Spec/Provider/GroupGetSpecProvider.php b/Civi/Api4/Service/Spec/Provider/GroupGetSpecProvider.php index 969f5c5e3197..2aeb05c9b7b7 100644 --- a/Civi/Api4/Service/Spec/Provider/GroupGetSpecProvider.php +++ b/Civi/Api4/Service/Spec/Provider/GroupGetSpecProvider.php @@ -35,6 +35,15 @@ public function modifySpec(RequestSpec $spec): void { ->setReadonly(TRUE) ->setSqlRenderer([__CLASS__, 'countContacts']); $spec->addFieldSpec($field); + + // Calculated field to check smart group cache status + $field = new FieldSpec('cache_expired', 'Group', 'Boolean'); + $field->setLabel(ts('Cache Expired')) + ->setDescription(ts('Is the smart group cache expired')) + ->setColumnName('cache_date') + ->setReadonly(TRUE) + ->setSqlRenderer([__CLASS__, 'getCacheExpiredSQL']); + $spec->addFieldSpec($field); } /** @@ -44,7 +53,7 @@ public function modifySpec(RequestSpec $spec): void { * @return bool */ public function applies($entity, $action): bool { - return $entity === 'Group' && $action === 'get'; + return $entity === 'Group' && in_array($action, ['get', 'refresh'], TRUE); } /** @@ -60,4 +69,17 @@ public static function countContacts(array $field): string { )"; } + /** + * Generate SQL for checking cache expiration for smart groups and parent groups + * + * @return string + */ + public static function getCacheExpiredSQL(array $field): string { + $smartGroupCacheTimeoutDateTime = \CRM_Contact_BAO_GroupContactCache::getCacheInvalidDateTime(); + $cacheDate = $field['sql_name']; + $savedSearchId = substr_replace($field['sql_name'], 'saved_search_id', -11, -1); + $children = substr_replace($field['sql_name'], 'children', -11, -1); + return "IF(($savedSearchId IS NULL AND $children IS NULL) OR $cacheDate > $smartGroupCacheTimeoutDateTime, 0, 1)"; + } + } diff --git a/Civi/Api4/Utils/CoreUtil.php b/Civi/Api4/Utils/CoreUtil.php index 918e2d56d184..835d1347bcda 100644 --- a/Civi/Api4/Utils/CoreUtil.php +++ b/Civi/Api4/Utils/CoreUtil.php @@ -189,7 +189,7 @@ public static function checkAccessRecord(\Civi\Api4\Generic\AbstractAction $apiR // For get actions, just run a get and ACLs will be applied to the query. // It's a cheap trick and not as efficient as not running the query at all, // but BAO::checkAccess doesn't consistently check permissions for the "get" action. - if (is_a($apiRequest, '\Civi\Api4\Generic\DAOGetAction')) { + if (is_a($apiRequest, '\Civi\Api4\Generic\AbstractGetAction')) { return (bool) $apiRequest->addSelect('id')->addWhere('id', '=', $record['id'])->execute()->count(); } diff --git a/tests/phpunit/api/v4/Entity/GroupTest.php b/tests/phpunit/api/v4/Entity/GroupTest.php index b811c3394e04..2e95889c6879 100644 --- a/tests/phpunit/api/v4/Entity/GroupTest.php +++ b/tests/phpunit/api/v4/Entity/GroupTest.php @@ -28,6 +28,65 @@ */ class GroupTest extends Api4TestBase { + public function testSmartGroupCache(): void { + \Civi::settings()->set('smartGroupCacheTimeout', 5); + $savedSearch = $this->createTestRecord('SavedSearch', [ + 'api_entity' => 'Contact', + 'api_params' => [ + 'version' => 4, + 'select' => ['id'], + 'where' => [], + ], + ]); + $smartGroup = $this->createTestRecord('Group', [ + 'saved_search_id' => $savedSearch['id'], + ]); + $parentGroup = $this->createTestRecord('Group'); + $childGroup = $this->createTestRecord('Group', [ + 'parents' => [$parentGroup['id']], + ]); + $groupIds = [$smartGroup['id'], $parentGroup['id'], $childGroup['id']]; + + $get = Group::get(FALSE)->addWhere('id', 'IN', $groupIds) + ->addSelect('id', 'cache_date', 'cache_expired') + ->execute()->indexBy('id'); + // Static (non-parent) groups should always have a null cache_date and expired should always be false. + $this->assertNull($get[$childGroup['id']]['cache_date']); + $this->assertFalse($get[$childGroup['id']]['cache_expired']); + // The others will start off with no cache date + $this->assertNull($get[$parentGroup['id']]['cache_date']); + $this->assertTrue($get[$parentGroup['id']]['cache_expired']); + $this->assertNull($get[$smartGroup['id']]['cache_date']); + $this->assertTrue($get[$smartGroup['id']]['cache_expired']); + + $refresh = Group::refresh(FALSE) + ->addWhere('id', 'IN', $groupIds) + ->execute(); + $this->assertCount(2, $refresh); + + $get = Group::get(FALSE)->addWhere('id', 'IN', $groupIds) + ->addSelect('id', 'cache_date', 'cache_expired') + ->execute()->indexBy('id'); + $this->assertNull($get[$childGroup['id']]['cache_date']); + $this->assertFalse($get[$childGroup['id']]['cache_expired']); + $this->assertNotNull($get[$smartGroup['id']]['cache_date']); + $this->assertFalse($get[$smartGroup['id']]['cache_expired']); + + // Pretend the group was refreshed 6 minutes ago + $lastRefresh = date('YmdHis', strtotime("-6 minutes")); + \CRM_Core_DAO::executeQuery("UPDATE civicrm_group SET cache_date = $lastRefresh WHERE id = %1", [ + 1 => [$smartGroup['id'], 'Integer'], + ]); + + $get = Group::get(FALSE)->addWhere('id', 'IN', $groupIds) + ->addSelect('id', 'cache_date', 'cache_expired') + ->execute()->indexBy('id'); + $this->assertNull($get[$childGroup['id']]['cache_date']); + $this->assertFalse($get[$childGroup['id']]['cache_expired']); + $this->assertNotNull($get[$smartGroup['id']]['cache_date']); + $this->assertTrue($get[$smartGroup['id']]['cache_expired']); + } + public function testCreate() { $this->createLoggedInUser(); \CRM_Core_Config::singleton()->userPermissionClass->permissions = [