diff --git a/CRM/Campaign/Form/Task.php b/CRM/Campaign/Form/Task.php index a7e464ad83a..5ad1593f13d 100644 --- a/CRM/Campaign/Form/Task.php +++ b/CRM/Campaign/Form/Task.php @@ -65,7 +65,7 @@ public function preProcess() { else { $qfKey = CRM_Utils_Request::retrieve('qfKey', 'String', $this); $cacheKey = "civicrm search {$qfKey}"; - $allCids = CRM_Core_BAO_PrevNextCache::getSelection($cacheKey, "getall"); + $allCids = Civi::service('prevnext')->getSelection($cacheKey, "getall"); $ids = array_keys($allCids[$cacheKey]); $this->assign('totalSelectedVoters', count($ids)); } diff --git a/CRM/Campaign/Selector/Search.php b/CRM/Campaign/Selector/Search.php index 6f7d3fccab9..535a7834954 100644 --- a/CRM/Campaign/Selector/Search.php +++ b/CRM/Campaign/Selector/Search.php @@ -271,7 +271,7 @@ public function buildPrevNextCache($sort) { if (!$crmPID) { $cacheKey = "civicrm search {$this->_key}"; - CRM_Core_BAO_PrevNextCache::deleteItem(NULL, $cacheKey, 'civicrm_contact'); + Civi::service('prevnext')->deleteItem(NULL, $cacheKey, 'civicrm_contact'); $sql = $this->_query->searchQuery(0, 0, $sort, FALSE, FALSE, @@ -281,18 +281,19 @@ public function buildPrevNextCache($sort) { $this->_campaignFromClause ); list($select, $from) = explode(' FROM ', $sql); - $insertSQL = " -INSERT INTO civicrm_prevnext_cache ( entity_table, entity_id1, entity_id2, cacheKey, data ) -SELECT 'civicrm_contact', contact_a.id, contact_a.id, '$cacheKey', contact_a.display_name + $selectSQL = " + SELECT '$cacheKey', contact_a.id, contact_a.display_name FROM {$from} "; - $errorScope = CRM_Core_TemporaryErrorScope::ignoreException(); - $result = CRM_Core_DAO::executeQuery($insertSQL); - unset($errorScope); - if (is_a($result, 'DB_Error')) { + try { + Civi::service('prevnext')->fillWithSql($cacheKey, $selectSQL); + } + catch (CRM_Core_Exception $e) { + // Heavy handed, no? Seems like this merits an explanation. return; } + // also record an entry in the cache key table, so we can delete it periodically CRM_Core_BAO_Cache::setItem($cacheKey, 'CiviCRM Search PrevNextCache', $cacheKey); } diff --git a/CRM/Contact/BAO/Contact.php b/CRM/Contact/BAO/Contact.php index f222e19594a..9e4488208c0 100644 --- a/CRM/Contact/BAO/Contact.php +++ b/CRM/Contact/BAO/Contact.php @@ -1013,7 +1013,10 @@ public static function deleteContact($id, $restore = FALSE, $skipUndelete = FALS CRM_Utils_Recent::delContact($id); self::updateContactCache($id, empty($restore)); - // delete any dupe cache entry + // delete any prevnext/dupe cache entry + // These two calls are redundant in default deployments, but they're + // meaningful if "prevnext" is memory-backed. + Civi::service('prevnext')->deleteItem($id); CRM_Core_BAO_PrevNextCache::deleteItem($id); $transaction->commit(); diff --git a/CRM/Contact/BAO/Contact/Utils.php b/CRM/Contact/BAO/Contact/Utils.php index f3b391362af..2cee6e99dcb 100644 --- a/CRM/Contact/BAO/Contact/Utils.php +++ b/CRM/Contact/BAO/Contact/Utils.php @@ -917,6 +917,9 @@ public static function clearContactCaches($isEmptyPrevNextTable = FALSE) { return; } if ($isEmptyPrevNextTable) { + // These two calls are redundant in default deployments, but they're + // meaningful if "prevnext" is memory-backed. + Civi::service('prevnext')->deleteItem(); CRM_Core_BAO_PrevNextCache::deleteItem(); } // clear acl cache if any. diff --git a/CRM/Contact/BAO/Query.php b/CRM/Contact/BAO/Query.php index 983162e4a81..72420b4fca4 100644 --- a/CRM/Contact/BAO/Query.php +++ b/CRM/Contact/BAO/Query.php @@ -4965,29 +4965,47 @@ public function searchQuery( } /** - * Fetch a list of contacts from the prev/next cache for displaying a search results page + * Fetch a list of contacts for displaying a search results page * - * @param string $cacheKey - * @param int $offset - * @param int $rowCount + * @param array $cids + * List of contact IDs * @param bool $includeContactIds * @return CRM_Core_DAO */ - public function getCachedContacts($cacheKey, $offset, $rowCount, $includeContactIds) { + public function getCachedContacts($cids, $includeContactIds) { + CRM_Utils_Type::validateAll($cids, 'Positive'); $this->_includeContactIds = $includeContactIds; $onlyDeleted = in_array(array('deleted_contacts', '=', '1', '0', '0'), $this->_params); list($select, $from, $where) = $this->query(FALSE, FALSE, FALSE, $onlyDeleted); - $from = " FROM civicrm_prevnext_cache pnc INNER JOIN civicrm_contact contact_a ON contact_a.id = pnc.entity_id1 AND pnc.cacheKey = '$cacheKey' " . substr($from, 31); - $order = " ORDER BY pnc.id"; - $groupByCol = array('contact_a.id', 'pnc.id'); - $select = self::appendAnyValueToSelect($this->_select, $groupByCol, 'GROUP_CONCAT'); - $groupBy = " GROUP BY " . implode(', ', $groupByCol); - $limit = " LIMIT $offset, $rowCount"; + $select .= sprintf(", (%s) AS _wgt", $this->createSqlCase('contact_a.id', $cids)); + $where .= sprintf(' AND contact_a.id IN (%s)', implode(',', $cids)); + $order = 'ORDER BY _wgt'; + $groupBy = ''; + $limit = ''; $query = "$select $from $where $groupBy $order $limit"; return CRM_Core_DAO::executeQuery($query); } + /** + * Construct a SQL CASE expression. + * + * @param string $idCol + * The name of a column with ID's (eg 'contact_a.id'). + * @param array $cids + * Array(int $weight => int $id). + * @return string + * CASE WHEN id=123 THEN 1 WHEN id=456 THEN 2 END + */ + private function createSqlCase($idCol, $cids) { + $buf = "CASE\n"; + foreach ($cids as $weight => $cid) { + $buf .= " WHEN $idCol = $cid THEN $weight \n"; + } + $buf .= "END\n"; + return $buf; + } + /** * Populate $this->_permissionWhereClause with permission related clause and update other * query related properties. diff --git a/CRM/Contact/Form/Search.php b/CRM/Contact/Form/Search.php index 5b9a2079dd6..a9ea4d57d99 100644 --- a/CRM/Contact/Form/Search.php +++ b/CRM/Contact/Form/Search.php @@ -497,7 +497,7 @@ public function buildQuickForm() { if ($qfKeyParam && ($this->get('component_mode') <= CRM_Contact_BAO_Query::MODE_CONTACTS || $this->get('component_mode') == CRM_Contact_BAO_Query::MODE_CONTACTSRELATED)) { $this->addClass('crm-ajax-selection-form'); $qfKeyParam = "civicrm search {$qfKeyParam}"; - $selectedContactIdsArr = CRM_Core_BAO_PrevNextCache::getSelection($qfKeyParam); + $selectedContactIdsArr = Civi::service('prevnext')->getSelection($qfKeyParam); $selectedContactIds = array_keys($selectedContactIdsArr[$qfKeyParam]); } @@ -782,7 +782,7 @@ public function postProcess() { ) { //reset the cache table for new search $cacheKey = "civicrm search {$this->controller->_key}"; - CRM_Core_BAO_PrevNextCache::deleteItem(NULL, $cacheKey); + Civi::service('prevnext')->deleteItem(NULL, $cacheKey); } //get the button name diff --git a/CRM/Contact/Form/Task.php b/CRM/Contact/Form/Task.php index 2d6e7637bf7..2c03f1e7233 100644 --- a/CRM/Contact/Form/Task.php +++ b/CRM/Contact/Form/Task.php @@ -165,14 +165,11 @@ public static function preProcessCommon(&$form) { if ((CRM_Utils_Array::value('radio_ts', self::$_searchFormValues) == 'ts_all') || ($form->_task == CRM_Contact_Task::SAVE_SEARCH) ) { - $sortByCharacter = $form->get('sortByCharacter'); - $cacheKey = ($sortByCharacter && $sortByCharacter != 'all') ? "{$cacheKey}_alphabet" : $cacheKey; - // since we don't store all contacts in prevnextcache, when user selects "all" use query to retrieve contacts // rather than prevnext cache table for most of the task actions except export where we rebuild query to fetch // final result set if ($useTable) { - $allCids = CRM_Core_BAO_PrevNextCache::getSelection($cacheKey, "getall"); + $allCids = Civi::service('prevnext')->getSelection($cacheKey, "getall"); } else { $allCids[$cacheKey] = self::getContactIds($form); @@ -233,7 +230,7 @@ public static function preProcessCommon(&$form) { } else { // fetching selected contact ids of passed cache key - $selectedCids = CRM_Core_BAO_PrevNextCache::getSelection($cacheKey); + $selectedCids = Civi::service('prevnext')->getSelection($cacheKey); foreach ($selectedCids[$cacheKey] as $selectedCid => $ignore) { if ($useTable) { $insertString[] = " ( {$selectedCid} ) "; @@ -273,7 +270,7 @@ public static function preProcessCommon(&$form) { ) { $sel = CRM_Utils_Array::value('radio_ts', self::$_searchFormValues); $form->assign('searchtype', $sel); - $result = CRM_Core_BAO_PrevNextCache::getSelectedContacts(); + $result = self::getSelectedContactNames(); $form->assign("value", $result); } @@ -477,6 +474,29 @@ public function mergeContactIdsByHousehold() { } } + /** + * @return array + * List of contact names. + * NOTE: These are raw values from the DB. In current data-model, that means + * they are pre-encoded HTML. + */ + private static function getSelectedContactNames() { + $qfKey = CRM_Utils_Request::retrieve('qfKey', 'String'); + $cacheKey = "civicrm search {$qfKey}"; + + $cids = array(); + // Gymanstic time! + foreach (Civi::service('prevnext')->getSelection($cacheKey) as $cacheKey => $values) { + $cids = array_unique(array_merge($cids, array_keys($values))); + } + + $result = CRM_Utils_SQL_Select::from('civicrm_contact') + ->where('id IN (#cids)', ['cids' => $cids]) + ->execute() + ->fetchMap('id', 'sort_name'); + return $result; + } + /** * Given this task's list of targets, produce a hidden group. * diff --git a/CRM/Contact/Page/AJAX.php b/CRM/Contact/Page/AJAX.php index 79fa957b6c8..8270d7b753a 100644 --- a/CRM/Contact/Page/AJAX.php +++ b/CRM/Contact/Page/AJAX.php @@ -966,19 +966,19 @@ public static function selectUnselectContacts() { $elements[$key] = self::_convertToId($element); } CRM_Utils_Type::escapeAll($elements, 'Integer'); - CRM_Core_BAO_PrevNextCache::markSelection($cacheKey, $actionToPerform, $elements); + Civi::service('prevnext')->markSelection($cacheKey, $actionToPerform, $elements); } else { - CRM_Core_BAO_PrevNextCache::markSelection($cacheKey, $actionToPerform); + Civi::service('prevnext')->markSelection($cacheKey, $actionToPerform); } } elseif ($variableType == 'single') { $cId = self::_convertToId($name); CRM_Utils_Type::escape($cId, 'Integer'); $action = ($state == 'checked') ? 'select' : 'unselect'; - CRM_Core_BAO_PrevNextCache::markSelection($cacheKey, $action, $cId); + Civi::service('prevnext')->markSelection($cacheKey, $action, $cId); } - $contactIds = CRM_Core_BAO_PrevNextCache::getSelection($cacheKey); + $contactIds = Civi::service('prevnext')->getSelection($cacheKey); $countSelectionCids = count($contactIds[$cacheKey]); $arrRet = array('getCount' => $countSelectionCids); diff --git a/CRM/Contact/Page/View.php b/CRM/Contact/Page/View.php index 4da77a244a8..f7e0057eefa 100644 --- a/CRM/Contact/Page/View.php +++ b/CRM/Contact/Page/View.php @@ -117,7 +117,7 @@ public function preProcess() { 'nextPrevError' => 0, ); if ($qfKey) { - $pos = CRM_Core_BAO_PrevNextCache::getPositions("civicrm search $qfKey", + $pos = Civi::service('prevnext')->getPositions("civicrm search $qfKey", $this->_contactId, $this->_contactId ); diff --git a/CRM/Contact/Selector.php b/CRM/Contact/Selector.php index c19789fa539..c9d62d93a4c 100644 --- a/CRM/Contact/Selector.php +++ b/CRM/Contact/Selector.php @@ -578,8 +578,11 @@ public function &getRows($action, $offset, $rowCount, $sort, $output = NULL) { // and contain the search criteria (parameters) // note that the default action is basic if ($rowCount) { + /** @var CRM_Core_PrevNextCache_Interface $prevNext */ + $prevNext = Civi::service('prevnext'); $cacheKey = $this->buildPrevNextCache($sort); - $resultSet = $this->_query->getCachedContacts($cacheKey, $offset, $rowCount, $includeContactIds)->fetchGenerator(); + $cids = $prevNext->fetch($cacheKey, $offset, $rowCount); + $resultSet = $this->_query->getCachedContacts($cids, $includeContactIds)->fetchGenerator(); } else { $resultSet = $this->_query->searchQuery($offset, $rowCount, $sort, FALSE, $includeContactIds)->fetchGenerator(); @@ -881,7 +884,7 @@ public function buildPrevNextCache($sort) { // check for current != previous to ensure cache is not reset if paging is done without changing // sort criteria if (!$pageNum || (!empty($currentSortID) && $currentSortID != $previousSortID)) { - CRM_Core_BAO_PrevNextCache::deleteItem(NULL, $cacheKey, 'civicrm_contact'); + Civi::service('prevnext')->deleteItem(NULL, $cacheKey, 'civicrm_contact'); // this means it's fresh search, so set pageNum=1 if (!$pageNum) { $pageNum = 1; @@ -900,10 +903,9 @@ public function buildPrevNextCache($sort) { $sortByCharacter = CRM_Utils_Request::retrieve('sortByCharacter', 'String'); //for text field pagination selection save - $countRow = CRM_Core_BAO_PrevNextCache::getCount($cacheKey, NULL, "entity_table = 'civicrm_contact'"); + $countRow = Civi::service('prevnext')->getCount($cacheKey); // $sortByCharacter triggers a refresh in the prevNext cache if ($sortByCharacter && $sortByCharacter != 'all') { - $cacheKey .= "_alphabet"; $this->fillupPrevNextCache($sort, $cacheKey, 0, max(self::CACHE_SIZE, $pageSize)); } elseif (($firstRecord + $pageSize) >= $countRow) { @@ -1039,17 +1041,11 @@ public function fillupPrevNextCache($sort, $cacheKey, $start = 0, $end = self::C // the other alternative of running the FULL query will just be incredibly inefficient // and slow things down way too much on large data sets / complex queries - $insertSQL = " -INSERT INTO civicrm_prevnext_cache ( entity_table, entity_id1, entity_id2, cacheKey, data ) -SELECT DISTINCT 'civicrm_contact', contact_a.id, contact_a.id, '$cacheKey', contact_a.sort_name -"; + $selectSQL = "SELECT DISTINCT '$cacheKey', contact_a.id, contact_a.sort_name"; - $sql = str_replace(array("SELECT contact_a.id as contact_id", "SELECT contact_a.id as id"), $insertSQL, $sql); + $sql = str_replace(array("SELECT contact_a.id as contact_id", "SELECT contact_a.id as id"), $selectSQL, $sql); try { - $result = CRM_Core_DAO::executeQuery($sql, [], FALSE, NULL, FALSE, TRUE, TRUE); - if (is_a($result, 'DB_Error')) { - throw new CRM_Core_Exception($result->message); - } + Civi::service('prevnext')->fillWithSql($cacheKey, $sql); } catch (CRM_Core_Exception $e) { if ($coreSearch) { @@ -1089,18 +1085,17 @@ public function rebuildPreNextCache($start, $end, $sort, $cacheKey) { $dao = CRM_Core_DAO::executeQuery($sql); // build insert query, note that currently we build cache for 500 (self::CACHE_SIZE) contact records at a time, hence below approach - $insertValues = array(); + $rows = []; while ($dao->fetch()) { - $insertValues[] = "('civicrm_contact', {$dao->contact_id}, {$dao->contact_id}, '{$cacheKey}', '" . CRM_Core_DAO::escapeString($dao->sort_name) . "')"; + $rows[] = [ + 'entity_table' => 'civicrm_contact', + 'entity_id1' => $dao->contact_id, + 'entity_id2' => $dao->contact_id, + 'data' => $dao->sort_name, + ]; } - //update pre/next cache using single insert query - if (!empty($insertValues)) { - $sql = 'INSERT INTO civicrm_prevnext_cache ( entity_table, entity_id1, entity_id2, cacheKey, data ) VALUES -' . implode(',', $insertValues); - - $result = CRM_Core_DAO::executeQuery($sql); - } + Civi::service('prevnext')->fillWithArray($cacheKey, $rows); } /** diff --git a/CRM/Core/BAO/PrevNextCache.php b/CRM/Core/BAO/PrevNextCache.php index 084f7709ceb..c3078140ac4 100644 --- a/CRM/Core/BAO/PrevNextCache.php +++ b/CRM/Core/BAO/PrevNextCache.php @@ -438,125 +438,18 @@ public static function cleanupCache() { CRM_Core_DAO::executeQuery($sql, $params); } - /** - * Save checkbox selections. - * - * @param $cacheKey - * @param string $action - * @param array $cIds - * @param string $entity_table - */ - public static function markSelection($cacheKey, $action = 'unselect', $cIds = NULL, $entity_table = 'civicrm_contact') { - if (!$cacheKey) { - return; - } - $params = array(); - - $entity_whereClause = " AND entity_table = '{$entity_table}'"; - if ($cIds && $cacheKey && $action) { - if (is_array($cIds)) { - $cIdFilter = "(" . implode(',', $cIds) . ")"; - $whereClause = " -WHERE cacheKey LIKE %1 -AND (entity_id1 IN {$cIdFilter} OR entity_id2 IN {$cIdFilter}) -"; - } - else { - $whereClause = " -WHERE cacheKey LIKE %1 -AND (entity_id1 = %2 OR entity_id2 = %2) -"; - $params[2] = array("{$cIds}", 'Integer'); - } - if ($action == 'select') { - $whereClause .= "AND is_selected = 0"; - $sql = "UPDATE civicrm_prevnext_cache SET is_selected = 1 {$whereClause} {$entity_whereClause}"; - $params[1] = array("{$cacheKey}%", 'String'); - } - elseif ($action == 'unselect') { - $whereClause .= "AND is_selected = 1"; - $sql = "UPDATE civicrm_prevnext_cache SET is_selected = 0 {$whereClause} {$entity_whereClause}"; - $params[1] = array("%{$cacheKey}%", 'String'); - } - // default action is reseting - } - elseif (!$cIds && $cacheKey && $action == 'unselect') { - $sql = " -UPDATE civicrm_prevnext_cache -SET is_selected = 0 -WHERE cacheKey LIKE %1 AND is_selected = 1 - {$entity_whereClause} -"; - $params[1] = array("{$cacheKey}%", 'String'); - } - CRM_Core_DAO::executeQuery($sql, $params); - } /** * Get the selections. * - * @param string $cacheKey - * Cache key. - * @param string $action - * Action. - * $action : get - get only selection records - * getall - get all the records of the specified cache key - * @param string $entity_table - * Entity table. + * NOTE: This stub has been preserved because one extension in `universe` + * was referencing the function. * - * @return array|NULL + * @deprecated + * @see CRM_Core_PrevNextCache_Sql::getSelection() */ - public static function getSelection($cacheKey, $action = 'get', $entity_table = 'civicrm_contact') { - if (!$cacheKey) { - return NULL; - } - $params = array(); - - $entity_whereClause = " AND entity_table = '{$entity_table}'"; - if ($cacheKey && ($action == 'get' || $action == 'getall')) { - $actionGet = ($action == "get") ? " AND is_selected = 1 " : ""; - $sql = " -SELECT entity_id1, entity_id2 FROM civicrm_prevnext_cache -WHERE cacheKey LIKE %1 - $actionGet - $entity_whereClause -ORDER BY id -"; - $params[1] = array("{$cacheKey}%", 'String'); - - $contactIds = array($cacheKey => array()); - $cIdDao = CRM_Core_DAO::executeQuery($sql, $params); - while ($cIdDao->fetch()) { - if ($cIdDao->entity_id1 == $cIdDao->entity_id2) { - $contactIds[$cacheKey][$cIdDao->entity_id1] = 1; - } - } - return $contactIds; - } - } - - /** - * @return array - */ - public static function getSelectedContacts() { - $qfKey = CRM_Utils_Request::retrieve('qfKey', 'String'); - $cacheKey = "civicrm search {$qfKey}"; - $query = " -SELECT * -FROM civicrm_prevnext_cache -WHERE cacheKey LIKE %1 - AND is_selected=1 - AND cacheKey NOT LIKE %2 -"; - $params1[1] = array("{$cacheKey}%", 'String'); - $params1[2] = array("{$cacheKey}_alphabet%", 'String'); - $dao = CRM_Core_DAO::executeQuery($query, $params1); - - $val = array(); - while ($dao->fetch()) { - $val[] = $dao->data; - } - return $val; + public static function getSelection($cacheKey, $action = 'get') { + return Civi::service('prevnext')->getSelection($cacheKey, $action); } /** diff --git a/CRM/Core/DAO/PrevNextCache.php b/CRM/Core/DAO/PrevNextCache.php index bc01e75e1c9..c55e0b7369d 100644 --- a/CRM/Core/DAO/PrevNextCache.php +++ b/CRM/Core/DAO/PrevNextCache.php @@ -6,7 +6,7 @@ * * Generated from xml/schema/CRM/Core/PrevNextCache.xml * DO NOT EDIT. Generated by CRM_Core_CodeGen - * (GenCodeChecksum:cfce4435348e53ba9941ce5ed223c05b) + * (GenCodeChecksum:5e4976ab94ea074a01a14e9eb0dde913) */ /** @@ -127,7 +127,7 @@ public static function &fields() { 'type' => CRM_Utils_Type::T_INT, 'title' => ts('Prev Next Entity ID 2'), 'description' => 'FK to entity table specified in entity_table column.', - 'required' => TRUE, + 'required' => FALSE, 'table_name' => 'civicrm_prevnext_cache', 'entity' => 'PrevNextCache', 'bao' => 'CRM_Core_BAO_PrevNextCache', diff --git a/CRM/Core/PrevNextCache/Interface.php b/CRM/Core/PrevNextCache/Interface.php new file mode 100644 index 00000000000..45f393f691f --- /dev/null +++ b/CRM/Core/PrevNextCache/Interface.php @@ -0,0 +1,128 @@ + 1, + * 'prev' => ['id1' => 123, 'data'=>'foo'], + * 'next' => ['id1' => 456, 'data'=>'foo'], + * ] + */ + public function getPositions($cacheKey, $id1); + + /** + * Delete an item from the prevnext cache table based on the entity. + * + * @param int $id + * @param string $cacheKey + */ + public function deleteItem($id = NULL, $cacheKey = NULL); + + /** + * Get count of matching rows. + * + * @param string $cacheKey + * @return int + */ + public function getCount($cacheKey); + +} diff --git a/CRM/Core/PrevNextCache/Redis.php b/CRM/Core/PrevNextCache/Redis.php new file mode 100644 index 00000000000..bc7d05b4d7b --- /dev/null +++ b/CRM/Core/PrevNextCache/Redis.php @@ -0,0 +1,256 @@ +redis = CRM_Utils_Cache_Redis::connect($settings); + $this->prefix = isset($settings['prefix']) ? $settings['prefix'] : ''; + $this->prefix .= \CRM_Utils_Cache::DELIMITER . 'prevnext' . \CRM_Utils_Cache::DELIMITER; + } + + public function fillWithSql($cacheKey, $sql) { + $dao = CRM_Core_DAO::executeQuery($sql, [], FALSE, NULL, FALSE, TRUE, TRUE); + if (is_a($dao, 'DB_Error')) { + throw new CRM_Core_Exception($dao->message); + } + + list($allKey, $dataKey, , $maxScore) = $this->initCacheKey($cacheKey); + + while ($dao->fetch()) { + list (, $entity_id, $data) = array_values($dao->toArray()); + $maxScore++; + $this->redis->zAdd($allKey, $maxScore, $entity_id); + $this->redis->hSet($dataKey, $entity_id, $data); + } + + $dao->free(); + return TRUE; + } + + public function fillWithArray($cacheKey, $rows) { + list($allKey, $dataKey, , $maxScore) = $this->initCacheKey($cacheKey); + + foreach ($rows as $row) { + $maxScore++; + $this->redis->zAdd($allKey, $maxScore, $row['entity_id1']); + $this->redis->hSet($dataKey, $row['entity_id1'], $row['data']); + } + + return TRUE; + } + + public function fetch($cacheKey, $offset, $rowCount) { + $allKey = $this->key($cacheKey, 'all'); + return $this->redis->zRange($allKey, $offset, $offset + $rowCount - 1); + } + + public function markSelection($cacheKey, $action, $ids = NULL) { + $allKey = $this->key($cacheKey, 'all'); + $selKey = $this->key($cacheKey, 'sel'); + + if ($action === 'select') { + foreach ((array) $ids as $id) { + $score = $this->redis->zScore($allKey, $id); + $this->redis->zAdd($selKey, $score, $id); + } + } + elseif ($action === 'unselect' && $ids === NULL) { + $this->redis->delete($selKey); + $this->redis->setTimeout($selKey, self::TTL); + } + elseif ($action === 'unselect' && $ids !== NULL) { + foreach ((array) $ids as $id) { + $this->redis->zDelete($selKey, $id); + } + } + } + + public function getSelection($cacheKey, $action = 'get') { + $allKey = $this->key($cacheKey, 'all'); + $selKey = $this->key($cacheKey, 'sel'); + + if ($action === 'get') { + $result = []; + foreach ($this->redis->zRange($selKey, 0, -1) as $entity_id) { + $result[$entity_id] = 1; + } + return [$cacheKey => $result]; + } + elseif ($action === 'getall') { + $result = []; + foreach ($this->redis->zRange($allKey, 0, -1) as $entity_id) { + $result[$entity_id] = 1; + } + return [$cacheKey => $result]; + } + else { + throw new \CRM_Core_Exception("Unrecognized action: $action"); + } + } + + public function getPositions($cacheKey, $id1) { + $allKey = $this->key($cacheKey, 'all'); + $dataKey = $this->key($cacheKey, 'data'); + + $rank = $this->redis->zRank($allKey, $id1); + if (!is_int($rank) || $rank < 0) { + return ['foundEntry' => 0]; + } + + $pos = ['foundEntry' => 1]; + + if ($rank > 0) { + $pos['prev'] = []; + foreach ($this->redis->zRange($allKey, $rank - 1, $rank - 1) as $value) { + $pos['prev']['id1'] = $value; + } + $pos['prev']['data'] = $this->redis->hGet($dataKey, $pos['prev']['id1']); + } + + $count = $this->getCount($cacheKey); + if ($count > $rank + 1) { + $pos['next'] = []; + foreach ($this->redis->zRange($allKey, $rank + 1, $rank + 1) as $value) { + $pos['next']['id1'] = $value; + } + $pos['next']['data'] = $this->redis->hGet($dataKey, $pos['next']['id1']); + } + + return $pos; + } + + public function deleteItem($id = NULL, $cacheKey = NULL) { + if ($id === NULL && $cacheKey !== NULL) { + // Delete by cacheKey. + $allKey = $this->key($cacheKey, 'all'); + $selKey = $this->key($cacheKey, 'sel'); + $dataKey = $this->key($cacheKey, 'data'); + $this->redis->delete($allKey, $selKey, $dataKey); + } + elseif ($id === NULL && $cacheKey === NULL) { + // Delete everything. + $keys = $this->redis->keys($this->prefix . '*'); + $this->redis->del($keys); + } + elseif ($id !== NULL && $cacheKey !== NULL) { + // Delete a specific contact, within a specific cache. + $this->redis->zDelete($this->key($cacheKey, 'all'), $id); + $this->redis->zDelete($this->key($cacheKey, 'sel'), $id); + $this->redis->hDel($this->key($cacheKey, 'data'), $id); + } + elseif ($id !== NULL && $cacheKey === NULL) { + // Delete a specific contact, across all prevnext caches. + $allKeys = $this->redis->keys($this->key('*', 'all')); + foreach ($allKeys as $allKey) { + $parts = explode(\CRM_Utils_Cache::DELIMITER, $allKey); + array_pop($parts); + $tmpCacheKey = array_pop($parts); + $this->deleteItem($id, $tmpCacheKey); + } + } + else { + throw new CRM_Core_Exception("Not implemented: Redis::deleteItem"); + } + } + + public function getCount($cacheKey) { + $allKey = $this->key($cacheKey, 'all'); + return $this->redis->zSize($allKey); + } + + /** + * Construct the full path to a cache item. + * + * @param string $cacheKey + * Identifier for this saved search. + * Ex: 'abcd1234abcd1234'. + * @param string $item + * Ex: 'list', 'rel', 'data'. + * @return string + * Ex: 'dmaster/prevnext/abcd1234abcd1234/list' + */ + private function key($cacheKey, $item) { + return $this->prefix . $cacheKey . \CRM_Utils_Cache::DELIMITER . $item; + } + + /** + * Initialize any data-structures or timeouts for the cache-key. + * + * This is non-destructive -- if data already exists, it's preserved. + * + * @return array + * 0 => string $allItemsCacheKey, + * 1 => string $dataItemsCacheKey, + * 2 => string $selectedItemsCacheKey, + * 3 => int $maxExistingScore + */ + private function initCacheKey($cacheKey) { + $allKey = $this->key($cacheKey, 'all'); + $selKey = $this->key($cacheKey, 'sel'); + $dataKey = $this->key($cacheKey, 'data'); + + $this->redis->setTimeout($allKey, self::TTL); + $this->redis->setTimeout($dataKey, self::TTL); + $this->redis->setTimeout($selKey, self::TTL); + + $maxScore = 0; + foreach ($this->redis->zRange($allKey, -1, -1, TRUE) as $lastElem => $lastScore) { + $maxScore = $lastScore; + } + return array($allKey, $dataKey, $selKey, $maxScore); + } + +} diff --git a/CRM/Core/PrevNextCache/Sql.php b/CRM/Core/PrevNextCache/Sql.php new file mode 100644 index 00000000000..adf0458f2a1 --- /dev/null +++ b/CRM/Core/PrevNextCache/Sql.php @@ -0,0 +1,270 @@ +message); + } + return TRUE; + } + + public function fillWithArray($cacheKey, $rows) { + if (empty($rows)) { + return; + } + + $insert = CRM_Utils_SQL_Insert::into('civicrm_prevnext_cache') + ->columns([ + 'entity_id1', + 'cacheKey', + 'data' + ]); + + foreach ($rows as &$row) { + $insert->row($row + ['cacheKey' => $cacheKey]); + } + + CRM_Core_DAO::executeQuery($insert->toSQL()); + return TRUE; + } + + /** + * Fetch a list of contacts from the prev/next cache for displaying a search results page + * + * @param string $cacheKey + * @param int $offset + * @param int $rowCount + * @return array + * List of contact IDs. + */ + public function fetch($cacheKey, $offset, $rowCount) { + $cids = array(); + $dao = CRM_Utils_SQL_Select::from('civicrm_prevnext_cache pnc') + ->where('pnc.cacheKey = @cacheKey', ['cacheKey' => $cacheKey]) + ->select('pnc.entity_id1 as cid') + ->orderBy('pnc.id') + ->limit($rowCount, $offset) + ->execute(); + while ($dao->fetch()) { + $cids[] = $dao->cid; + } + return $cids; + } + + /** + * Save checkbox selections. + * + * @param string $cacheKey + * @param string $action + * Ex: 'select', 'unselect'. + * @param array|int|NULL $ids + * A list of contact IDs to (un)select. + * To unselect all contact IDs, use NULL. + */ + public function markSelection($cacheKey, $action, $ids = NULL) { + if (!$cacheKey) { + return; + } + $params = array(); + + if ($ids && $cacheKey && $action) { + if (is_array($ids)) { + $cIdFilter = "(" . implode(',', $ids) . ")"; + $whereClause = " +WHERE cacheKey = %1 +AND (entity_id1 IN {$cIdFilter} OR entity_id2 IN {$cIdFilter}) +"; + } + else { + $whereClause = " +WHERE cacheKey = %1 +AND (entity_id1 = %2 OR entity_id2 = %2) +"; + $params[2] = array("{$ids}", 'Integer'); + } + if ($action == 'select') { + $whereClause .= "AND is_selected = 0"; + $sql = "UPDATE civicrm_prevnext_cache SET is_selected = 1 {$whereClause}"; + $params[1] = array($cacheKey, 'String'); + } + elseif ($action == 'unselect') { + $whereClause .= "AND is_selected = 1"; + $sql = "UPDATE civicrm_prevnext_cache SET is_selected = 0 {$whereClause}"; + $params[1] = array($cacheKey, 'String'); + } + // default action is reseting + } + elseif (!$ids && $cacheKey && $action == 'unselect') { + $sql = " +UPDATE civicrm_prevnext_cache +SET is_selected = 0 +WHERE cacheKey = %1 AND is_selected = 1 +"; + $params[1] = array($cacheKey, 'String'); + } + CRM_Core_DAO::executeQuery($sql, $params); + } + + /** + * Get the selections. + * + * @param string $cacheKey + * Cache key. + * @param string $action + * One of the following: + * - 'get' - get only selection records + * - 'getall' - get all the records of the specified cache key + * + * @return array|NULL + */ + public function getSelection($cacheKey, $action = 'get') { + if (!$cacheKey) { + return NULL; + } + $params = array(); + + if ($cacheKey && ($action == 'get' || $action == 'getall')) { + $actionGet = ($action == "get") ? " AND is_selected = 1 " : ""; + $sql = " +SELECT entity_id1 FROM civicrm_prevnext_cache +WHERE cacheKey = %1 + $actionGet +ORDER BY id +"; + $params[1] = array($cacheKey, 'String'); + + $contactIds = array($cacheKey => array()); + $cIdDao = CRM_Core_DAO::executeQuery($sql, $params); + while ($cIdDao->fetch()) { + $contactIds[$cacheKey][$cIdDao->entity_id1] = 1; + } + return $contactIds; + } + } + + /** + * Get the previous and next keys. + * + * @param string $cacheKey + * @param int $id1 + * + * @return array + */ + public function getPositions($cacheKey, $id1) { + $mergeId = CRM_Core_DAO::singleValueQuery( + "SELECT id FROM civicrm_prevnext_cache WHERE cacheKey = %2 AND entity_id1 = %1", + [ + 1 => [$id1, 'Integer'], + 2 => [$cacheKey, 'String'], + ] + ); + + $pos = ['foundEntry' => 0]; + if ($mergeId) { + $pos['foundEntry'] = 1; + + $sql = "SELECT pn.id, pn.entity_id1, pn.entity_id2, pn.data FROM civicrm_prevnext_cache pn "; + $wherePrev = " WHERE pn.id < %1 AND pn.cacheKey = %2 ORDER BY ID DESC LIMIT 1"; + $whereNext = " WHERE pn.id > %1 AND pn.cacheKey = %2 ORDER BY ID ASC LIMIT 1"; + $p = [ + 1 => [$mergeId, 'Integer'], + 2 => [$cacheKey, 'String'], + ]; + + $dao = CRM_Core_DAO::executeQuery($sql . $wherePrev, $p); + if ($dao->fetch()) { + $pos['prev']['id1'] = $dao->entity_id1; + $pos['prev']['mergeId'] = $dao->id; + $pos['prev']['data'] = $dao->data; + } + + $dao = CRM_Core_DAO::executeQuery($sql . $whereNext, $p); + if ($dao->fetch()) { + $pos['next']['id1'] = $dao->entity_id1; + $pos['next']['mergeId'] = $dao->id; + $pos['next']['data'] = $dao->data; + } + } + return $pos; + + } + + /** + * Delete an item from the prevnext cache table based on the entity. + * + * @param int $id + * @param string $cacheKey + */ + public function deleteItem($id = NULL, $cacheKey = NULL) { + $sql = "DELETE FROM civicrm_prevnext_cache WHERE (1)"; + $params = array(); + + if (is_numeric($id)) { + $sql .= " AND ( entity_id1 = %2 OR entity_id2 = %2 )"; + $params[2] = array($id, 'Integer'); + } + + if (isset($cacheKey)) { + $sql .= " AND cacheKey = %3"; + $params[3] = array($cacheKey, 'String'); + } + CRM_Core_DAO::executeQuery($sql, $params); + } + + /** + * Get count of matching rows. + * + * @param string $cacheKey + * @return int + */ + public function getCount($cacheKey) { + $query = "SELECT COUNT(*) FROM civicrm_prevnext_cache pn WHERE pn.cacheKey = %1"; + $params = [1 => [$cacheKey, 'String']]; + return (int) CRM_Core_DAO::singleValueQuery($query, $params, TRUE, FALSE); + } + +} diff --git a/CRM/Upgrade/Incremental/sql/5.5.alpha1.mysql.tpl b/CRM/Upgrade/Incremental/sql/5.5.alpha1.mysql.tpl index 7a3d52b0592..cce781a88cb 100644 --- a/CRM/Upgrade/Incremental/sql/5.5.alpha1.mysql.tpl +++ b/CRM/Upgrade/Incremental/sql/5.5.alpha1.mysql.tpl @@ -1,4 +1,8 @@ {* file to handle db changes in 5.5.alpha1 during upgrade *} + +ALTER TABLE civicrm_prevnext_cache + CHANGE `entity_id2` `entity_id2` int unsigned NULL COMMENT 'FK to entity table specified in entity_table column.'; + #https://lab.civicrm.org/dev/core/issues/228 UPDATE civicrm_option_group SET is_active = 0 WHERE is_active IS NULL; ALTER TABLE civicrm_option_group MODIFY COLUMN is_active TINYINT(4) NOT NULL DEFAULT 1 COMMENT 'Is this option group active?'; diff --git a/CRM/Utils/Cache.php b/CRM/Utils/Cache.php index 171e4d54374..358f260181e 100644 --- a/CRM/Utils/Cache.php +++ b/CRM/Utils/Cache.php @@ -67,22 +67,7 @@ public function __construct(&$config) { */ public static function &singleton() { if (self::$_singleton === NULL) { - $className = 'ArrayCache'; // default to ArrayCache for now - - // Maintain backward compatibility for now. - // Setting CIVICRM_USE_MEMCACHE or CIVICRM_USE_ARRAYCACHE will - // override the CIVICRM_DB_CACHE_CLASS setting. - // Going forward, CIVICRM_USE_xxxCACHE should be deprecated. - if (defined('CIVICRM_USE_MEMCACHE') && CIVICRM_USE_MEMCACHE) { - $className = 'Memcache'; - } - elseif (defined('CIVICRM_USE_ARRAYCACHE') && CIVICRM_USE_ARRAYCACHE) { - $className = 'ArrayCache'; - } - elseif (defined('CIVICRM_DB_CACHE_CLASS') && CIVICRM_DB_CACHE_CLASS) { - $className = CIVICRM_DB_CACHE_CLASS; - } - + $className = self::getCacheDriver(); // a generic method for utilizing any of the available db caches. $dbCacheClass = 'CRM_Utils_Cache_' . $className; $settings = self::getCacheSettings($className); @@ -240,4 +225,30 @@ public static function assertValidKey($key) { return $key; } + /** + * @return string + * Ex: 'ArrayCache', 'Memcache', 'Redis'. + */ + public static function getCacheDriver() { + $className = 'ArrayCache'; // default to ArrayCache for now + + // Maintain backward compatibility for now. + // Setting CIVICRM_USE_MEMCACHE or CIVICRM_USE_ARRAYCACHE will + // override the CIVICRM_DB_CACHE_CLASS setting. + // Going forward, CIVICRM_USE_xxxCACHE should be deprecated. + if (defined('CIVICRM_USE_MEMCACHE') && CIVICRM_USE_MEMCACHE) { + $className = 'Memcache'; + return $className; + } + elseif (defined('CIVICRM_USE_ARRAYCACHE') && CIVICRM_USE_ARRAYCACHE) { + $className = 'ArrayCache'; + return $className; + } + elseif (defined('CIVICRM_DB_CACHE_CLASS') && CIVICRM_DB_CACHE_CLASS) { + $className = CIVICRM_DB_CACHE_CLASS; + return $className; + } + return $className; + } + } diff --git a/CRM/Utils/Cache/Redis.php b/CRM/Utils/Cache/Redis.php index 3e2f2f7f9b3..ad1d07cd3a3 100644 --- a/CRM/Utils/Cache/Redis.php +++ b/CRM/Utils/Cache/Redis.php @@ -42,20 +42,6 @@ class CRM_Utils_Cache_Redis implements CRM_Utils_Cache_Interface { const DEFAULT_TIMEOUT = 3600; const DEFAULT_PREFIX = ''; - /** - * The host name of the redisd server - * - * @var string - */ - protected $_host = self::DEFAULT_HOST; - - /** - * The port on which to connect on - * - * @var int - */ - protected $_port = self::DEFAULT_PORT; - /** * The default timeout to use * @@ -81,6 +67,34 @@ class CRM_Utils_Cache_Redis implements CRM_Utils_Cache_Interface { */ protected $_cache; + /** + * Create a connection. If a connection already exists, re-use it. + * + * @param array $config + * @return Redis + */ + public static function connect($config) { + $host = isset($config['host']) ? $config['host'] : self::DEFAULT_HOST; + $port = isset($config['port']) ? $config['port'] : self::DEFAULT_PORT; + $pass = CRM_Utils_Constant::value('CIVICRM_DB_CACHE_PASSWORD'); // Ugh. + $id = implode(':', ['connect', $host, $port /* $pass is constant */]); + if (!isset(Civi::$statics[__CLASS__][$id])) { + // Ideally, we'd track the connection in the service-container, but the + // cache connection is boot-critical. + $redis = new Redis(); + if (!$redis->connect($host, $port)) { + // dont use fatal here since we can go in an infinite loop + echo 'Could not connect to redisd server'; + CRM_Utils_System::civiExit(); + } + if ($pass) { + $redis->auth($pass); + } + Civi::$statics[__CLASS__][$id] = $redis; + } + return Civi::$statics[__CLASS__][$id]; + } + /** * Constructor * @@ -90,12 +104,6 @@ class CRM_Utils_Cache_Redis implements CRM_Utils_Cache_Interface { * @return \CRM_Utils_Cache_Redis */ public function __construct($config) { - if (isset($config['host'])) { - $this->_host = $config['host']; - } - if (isset($config['port'])) { - $this->_port = $config['port']; - } if (isset($config['timeout'])) { $this->_timeout = $config['timeout']; } @@ -103,15 +111,7 @@ public function __construct($config) { $this->_prefix = $config['prefix']; } - $this->_cache = new Redis(); - if (!$this->_cache->connect($this->_host, $this->_port)) { - // dont use fatal here since we can go in an infinite loop - echo 'Could not connect to redisd server'; - CRM_Utils_System::civiExit(); - } - if (CRM_Utils_Constant::value('CIVICRM_DB_CACHE_PASSWORD')) { - $this->_cache->auth(CIVICRM_DB_CACHE_PASSWORD); - } + $this->_cache = self::connect($config); } /** diff --git a/Civi/Core/Container.php b/Civi/Core/Container.php index 82dc0614de6..03e12ea4fdd 100644 --- a/Civi/Core/Container.php +++ b/Civi/Core/Container.php @@ -214,6 +214,24 @@ public function createContainer() { ->setFactory(array($class, 'singleton')); } + $container->setDefinition('prevnext', new Definition( + 'CRM_Core_PrevNextCache_Interface', + [new Reference('service_container')] + ))->setFactory(array(new Reference(self::SELF), 'createPrevNextCache')); + + $container->setDefinition('prevnext.driver.sql', new Definition( + 'CRM_Core_PrevNextCache_Sql', + [] + )); + + $container->setDefinition('prevnext.driver.redis', new Definition( + 'CRM_Core_PrevNextCache_Redis', + [new Reference('cache_config')] + )); + + $container->setDefinition('cache_config', new Definition('ArrayObject')) + ->setFactory(array(new Reference(self::SELF), 'createCacheConfig')); + $container->setDefinition('civi.mailing.triggers', new Definition( 'Civi\Core\SqlTrigger\TimestampTriggers', array('civicrm_mailing', 'Mailing') @@ -399,6 +417,25 @@ public function createApiKernel($dispatcher, $magicFunctionProvider) { return $kernel; } + /** + * @param ContainerInterface $container + * @return \CRM_Core_PrevNextCache_Interface + */ + public static function createPrevNextCache($container) { + $cacheDriver = \CRM_Utils_Cache::getCacheDriver(); + $service = 'prevnext.driver.' . strtolower($cacheDriver); + return $container->has($service) + ? $container->get($service) + : $container->get('prevnext.driver.sql'); + } + + public static function createCacheConfig() { + $driver = \CRM_Utils_Cache::getCacheDriver(); + $settings = \CRM_Utils_Cache::getCacheSettings($driver); + $settings['driver'] = $driver; + return new \ArrayObject($settings); + } + /** * Get a list of boot services. * diff --git a/tests/phpunit/E2E/Core/PrevNextTest.php b/tests/phpunit/E2E/Core/PrevNextTest.php new file mode 100644 index 00000000000..d416e564b81 --- /dev/null +++ b/tests/phpunit/E2E/Core/PrevNextTest.php @@ -0,0 +1,332 @@ +prevNext = \Civi::service('prevnext'); + $this->cacheKey = 'PrevNextTest_' . \CRM_Utils_String::createRandom(16, \CRM_Utils_String::ALPHANUMERIC); + $this->cacheKeyB = 'PrevNextTest_' . \CRM_Utils_String::createRandom(16, \CRM_Utils_String::ALPHANUMERIC); + $this->assertTrue( + \CRM_Core_DAO::singleValueQuery('SELECT count(*) FROM civicrm_contact') > 25, + 'The contact table must have at least 25 records.' + ); + } + + protected function tearDown() { + \Civi::service('prevnext')->deleteItem(NULL, $this->cacheKey); + } + + public function testFillSql() { + $start = 0; + $prefillLimit = 25; + $sort = NULL; + + $query = new \CRM_Contact_BAO_Query(array(), NULL, NULL, FALSE, FALSE, 1, FALSE, TRUE, FALSE, NULL, 'AND'); + $sql = $query->searchQuery($start, $prefillLimit, $sort, FALSE, $query->_includeContactIds, + FALSE, TRUE, TRUE); + $selectSQL = "SELECT DISTINCT '$this->cacheKey', contact_a.id, contact_a.sort_name"; + $sql = str_replace(array("SELECT contact_a.id as contact_id", "SELECT contact_a.id as id"), $selectSQL, $sql); + + $this->assertTrue( + $this->prevNext->fillWithSql($this->cacheKey, $sql), + "fillWithSql should return TRUE on success" + ); + + $this->assertEquals(25, $this->prevNext->getCount($this->cacheKey)); + $this->assertEquals(0, $this->prevNext->getCount('not-a-key-' . $this->cacheKey)); + + $all = $this->prevNext->getSelection($this->cacheKey, 'getall')[$this->cacheKey]; + $this->assertCount($prefillLimit, $all); + $this->assertCount($prefillLimit, array_unique(array_keys($all))); + $this->assertEquals([1], array_unique(array_values($all))); + + $this->assertSelections([]); + } + + public function testFillArray() { + $rowSetA = [ + ['entity_id1' => 100, 'data' => 'Alice'], + ['entity_id1' => 400, 'data' => 'Bob'], + ['entity_id1' => 200, 'data' => 'Carol'], + ]; + $rowSetB = [ + ['entity_id1' => 300, 'data' => 'Dave'], + ]; + + $this->assertTrue( + $this->prevNext->fillWithArray($this->cacheKey, $rowSetA), + "fillWithArray should return TRUE on success" + ); + $this->assertTrue( + $this->prevNext->fillWithArray($this->cacheKey, $rowSetB), + "fillWithArray should return TRUE on success" + ); + + $this->assertEquals(4, $this->prevNext->getCount($this->cacheKey)); + $this->assertEquals(0, $this->prevNext->getCount('not-a-key-' . $this->cacheKey)); + + $all = $this->prevNext->getSelection($this->cacheKey, 'getall')[$this->cacheKey]; + $this->assertEquals([100, 400, 200, 300], array_keys($all)); + $this->assertEquals([1], array_unique(array_values($all))); + + $this->assertSelections([]); + } + + public function testFetch() { + $this->testFillArray(); + + $cids = $this->prevNext->fetch($this->cacheKey, 0, 2); + $this->assertEquals([100, 400], $cids); + + $cids = $this->prevNext->fetch($this->cacheKey, 0, 4); + $this->assertEquals([100, 400, 200, 300], $cids); + + $cids = $this->prevNext->fetch($this->cacheKey, 2, 2); + $this->assertEquals([200, 300], $cids); + } + + public function getFillFunctions() { + return [ + ['testFillSql'], + ['testFillArray'], + ]; + } + + /** + * Select and unselect one item. + * + * @dataProvider getFillFunctions + */ + public function testMarkSelection_1($fillFunction) { + call_user_func([$this, $fillFunction]); + + $all = $this->prevNext->getSelection($this->cacheKey, 'getall')[$this->cacheKey]; + list ($id1, $id2) = array_keys($all); + $this->prevNext->markSelection($this->cacheKey, 'select', $id1); + + $this->assertSelections([$id1]); + + $this->prevNext->markSelection($this->cacheKey, 'unselect', $id1); + $this->assertSelections([]); + } + + /** + * Select and unselect two items. + * + * @dataProvider getFillFunctions + */ + public function testMarkSelection_2($fillFunction) { + call_user_func([$this, $fillFunction]); + + $all = $this->prevNext->getSelection($this->cacheKey, 'getall')[$this->cacheKey]; + list ($id1, $id2, $id3) = array_keys($all); + + $this->prevNext->markSelection($this->cacheKey, 'select', [$id1, $id3]); + $this->assertSelections([$id1, $id3]); + + $this->prevNext->markSelection($this->cacheKey, 'unselect', $id1); + $this->assertSelections([$id3]); + + $this->prevNext->markSelection($this->cacheKey, 'select', $id2); + $this->assertSelections([$id2, $id3]); + + $this->prevNext->markSelection($this->cacheKey, 'unselect'); + $this->assertSelections([]); + } + + /** + * Check the neighbors of the first item. + * + * @dataProvider getFillFunctions + */ + public function testGetPosition_first($fillFunction) { + call_user_func([$this, $fillFunction]); + + $all = $this->prevNext->getSelection($this->cacheKey, 'getall')[$this->cacheKey]; + list ($id1, $id2, $id3) = array_keys($all); + + $pos = $this->prevNext->getPositions($this->cacheKey, $id1); + + $this->assertTrue((bool) $pos['foundEntry']); + + $this->assertEquals($id2, $pos['next']['id1']); + $this->assertTrue(!empty($pos['next']['data'])); + + $this->assertTrue(!isset($pos['prev'])); + } + + /** + * Check the neighbors of a middle item. + * + * @dataProvider getFillFunctions + */ + public function testGetPosition_middle($fillFunction) { + call_user_func([$this, $fillFunction]); + + $all = $this->prevNext->getSelection($this->cacheKey, 'getall')[$this->cacheKey]; + list ($id1, $id2, $id3) = array_keys($all); + + $pos = $this->prevNext->getPositions($this->cacheKey, $id2); + $this->assertTrue((bool) $pos['foundEntry']); + + $this->assertEquals($id3, $pos['next']['id1']); + $this->assertTrue(!empty($pos['next']['data'])); + + $this->assertEquals($id1, $pos['prev']['id1']); + $this->assertTrue(!empty($pos['prev']['data'])); + } + + /** + * Check the neighbors of the last item. + * + * @dataProvider getFillFunctions + */ + public function testGetPosition_last($fillFunction) { + call_user_func([$this, $fillFunction]); + + $all = $this->prevNext->getSelection($this->cacheKey, 'getall')[$this->cacheKey]; + list ($idLast, $idPrev) = array_reverse(array_keys($all)); + + $pos = $this->prevNext->getPositions($this->cacheKey, $idLast); + $this->assertTrue((bool) $pos['foundEntry']); + + $this->assertTrue(!isset($pos['next'])); + + $this->assertEquals($idPrev, $pos['prev']['id1']); + $this->assertTrue(!empty($pos['prev']['data'])); + } + + /** + * Check the neighbors of the last item. + * + * @dataProvider getFillFunctions + */ + public function testGetPosition_invalid($fillFunction) { + call_user_func([$this, $fillFunction]); + + $pos = $this->prevNext->getPositions($this->cacheKey, 99999999); + $this->assertFalse((bool) $pos['foundEntry']); + $this->assertTrue(!isset($pos['next'])); + $this->assertTrue(!isset($pos['prev'])); + } + + public function testDeleteByCacheKey() { + // Add background data + $this->prevNext->fillWithArray($this->cacheKeyB, [ + ['entity_id1' => 100, 'data' => 'Alice'], + ['entity_id1' => 150, 'data' => 'Dave'], + ]); + $this->prevNext->markSelection($this->cacheKeyB, 'select', 100); + $this->assertSelections([100], 'get', $this->cacheKeyB); + $this->assertSelections([100, 150], 'getall', $this->cacheKeyB); + + // Add some data that we're actually working with. + $this->testFillArray(); + + $all = $this->prevNext->getSelection($this->cacheKey, 'getall')[$this->cacheKey]; + $this->assertEquals([100, 400, 200, 300], array_keys($all)); + + list ($id1, $id2, $id3) = array_keys($all); + $this->prevNext->markSelection($this->cacheKey, 'select', [$id1, $id3]); + $this->assertSelections([$id1, $id3]); + + $this->prevNext->deleteItem(NULL, $this->cacheKey); + $all = $this->prevNext->getSelection($this->cacheKey, 'getall')[$this->cacheKey]; + $this->assertEquals([], array_keys($all)); + $this->assertSelections([]); + + // Ensure background data was untouched. + $this->assertSelections([100], 'get', $this->cacheKeyB); + $this->assertSelections([100, 150], 'getall', $this->cacheKeyB); + } + + public function testDeleteByEntityId() { + // Fill two caches + $this->prevNext->fillWithArray($this->cacheKey, [ + ['entity_id1' => 100, 'data' => 'Alice'], + ['entity_id1' => 150, 'data' => 'Dave'], + ]); + $this->prevNext->markSelection($this->cacheKey, 'select', 100); + $this->assertSelections([100], 'get', $this->cacheKey); + $this->assertSelections([100, 150], 'getall', $this->cacheKey); + + $this->prevNext->fillWithArray($this->cacheKeyB, [ + ['entity_id1' => 100, 'data' => 'Alice'], + ['entity_id1' => 400, 'data' => 'Bob'], + ]); + $this->prevNext->markSelection($this->cacheKeyB, 'select', [100, 400]); + $this->assertSelections([100, 400], 'get', $this->cacheKeyB); + $this->assertSelections([100, 400], 'getall', $this->cacheKeyB); + + // Delete + $this->prevNext->deleteItem(100); + $this->assertSelections([], 'get', $this->cacheKey); + $this->assertSelections([150], 'getall', $this->cacheKey); + $this->assertSelections([400], 'get', $this->cacheKeyB); + $this->assertSelections([400], 'getall', $this->cacheKeyB); + } + + public function testDeleteAll() { + // Fill two caches + $this->prevNext->fillWithArray($this->cacheKey, [ + ['entity_id1' => 100, 'data' => 'Alice'], + ['entity_id1' => 150, 'data' => 'Dave'], + ]); + $this->prevNext->markSelection($this->cacheKey, 'select', 100); + $this->assertSelections([100], 'get', $this->cacheKey); + $this->assertSelections([100, 150], 'getall', $this->cacheKey); + + $this->prevNext->fillWithArray($this->cacheKeyB, [ + ['entity_id1' => 100, 'data' => 'Alice'], + ['entity_id1' => 400, 'data' => 'Bob'], + ]); + $this->prevNext->markSelection($this->cacheKeyB, 'select', [100, 400]); + $this->assertSelections([100, 400], 'get', $this->cacheKeyB); + $this->assertSelections([100, 400], 'getall', $this->cacheKeyB); + + // Delete + $this->prevNext->deleteItem(NULL, NULL); + $this->assertSelections([], 'get', $this->cacheKey); + $this->assertSelections([], 'getall', $this->cacheKey); + $this->assertSelections([], 'get', $this->cacheKeyB); + $this->assertSelections([], 'getall', $this->cacheKeyB); + } + + + /** + * Assert that the current cacheKey has a list of selected contact IDs. + * + * @param array $ids + * Contact IDs that should be selected. + */ + protected function assertSelections($ids, $action = 'get', $cacheKey = NULL) { + if ($cacheKey === NULL) { + $cacheKey = $this->cacheKey; + } + $selected = $this->prevNext->getSelection($cacheKey, $action)[$cacheKey]; + $this->assertEquals($ids, array_keys($selected)); + $this->assertCount(count($ids), $selected); + } + +} diff --git a/xml/schema/Core/PrevNextCache.xml b/xml/schema/Core/PrevNextCache.xml index 5fda09bd84e..caee9ef7fa5 100644 --- a/xml/schema/Core/PrevNextCache.xml +++ b/xml/schema/Core/PrevNextCache.xml @@ -37,7 +37,7 @@ entity_id2 Prev Next Entity ID 2 int unsigned - true + false FK to entity table specified in entity_table column. 3.4