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 @@