diff --git a/src/applications/feed/query/PhabricatorFeedQuery.php b/src/applications/feed/query/PhabricatorFeedQuery.php
index a0851aeca0..5f3efe5aaf 100644
--- a/src/applications/feed/query/PhabricatorFeedQuery.php
+++ b/src/applications/feed/query/PhabricatorFeedQuery.php
@@ -1,126 +1,126 @@
 <?php
 
 final class PhabricatorFeedQuery
   extends PhabricatorCursorPagedPolicyAwareQuery {
 
   private $filterPHIDs;
   private $chronologicalKeys;
 
   public function setFilterPHIDs(array $phids) {
     $this->filterPHIDs = $phids;
     return $this;
   }
 
   public function withChronologicalKeys(array $keys) {
     $this->chronologicalKeys = $keys;
     return $this;
   }
 
   protected function loadPage() {
     $story_table = new PhabricatorFeedStoryData();
     $conn = $story_table->establishConnection('r');
 
     $data = queryfx_all(
       $conn,
       'SELECT story.* FROM %T story %Q %Q %Q %Q %Q',
       $story_table->getTableName(),
       $this->buildJoinClause($conn),
       $this->buildWhereClause($conn),
       $this->buildGroupClause($conn),
       $this->buildOrderClause($conn),
       $this->buildLimitClause($conn));
 
     return $data;
   }
 
   protected function willFilterPage(array $data) {
     return PhabricatorFeedStory::loadAllFromRows($data, $this->getViewer());
   }
 
   private function buildJoinClause(AphrontDatabaseConnection $conn_r) {
     // NOTE: We perform this join unconditionally (even if we have no filter
     // PHIDs) to omit rows which have no story references. These story data
     // rows are notifications or realtime alerts.
 
     $ref_table = new PhabricatorFeedStoryReference();
     return qsprintf(
       $conn_r,
       'JOIN %T ref ON ref.chronologicalKey = story.chronologicalKey',
       $ref_table->getTableName());
   }
 
   private function buildWhereClause(AphrontDatabaseConnection $conn_r) {
     $where = array();
 
     if ($this->filterPHIDs) {
       $where[] = qsprintf(
         $conn_r,
         'ref.objectPHID IN (%Ls)',
         $this->filterPHIDs);
     }
 
     if ($this->chronologicalKeys) {
       // NOTE: We want to use integers in the query so we can take advantage
       // of keys, but can't use %d on 32-bit systems. Make sure all the keys
       // are integers and then format them raw.
 
       $keys = $this->chronologicalKeys;
       foreach ($keys as $key) {
         if (!ctype_digit($key)) {
           throw new Exception("Key '{$key}' is not a valid chronological key!");
         }
       }
 
       $where[] = qsprintf(
         $conn_r,
         'ref.chronologicalKey IN (%Q)',
         implode(', ', $keys));
     }
 
     $where[] = $this->buildPagingClause($conn_r);
 
     return $this->formatWhereClause($where);
   }
 
   private function buildGroupClause(AphrontDatabaseConnection $conn_r) {
     if ($this->filterPHIDs) {
       return qsprintf($conn_r, 'GROUP BY ref.chronologicalKey');
     } else {
       return qsprintf($conn_r, 'GROUP BY story.chronologicalKey');
     }
   }
 
   protected function getDefaultOrderVector() {
     return array('key');
   }
 
   public function getOrderableColumns() {
     $table = ($this->filterPHIDs ? 'ref' : 'story');
     return array(
       'key' => array(
         'table' => $table,
         'column' => 'chronologicalKey',
         'type' => 'int',
         'unique' => true,
       ),
     );
   }
 
   protected function getPagingValueMap($cursor, array $keys) {
     return array(
       'key' => $cursor,
     );
   }
 
-  protected function getPagingValue($item) {
+  protected function getResultCursor($item) {
     if ($item instanceof PhabricatorFeedStory) {
       return $item->getChronologicalKey();
     }
     return $item['chronologicalKey'];
   }
 
   public function getQueryApplicationClass() {
     return 'PhabricatorFeedApplication';
   }
 
 }
diff --git a/src/applications/maniphest/query/ManiphestTaskQuery.php b/src/applications/maniphest/query/ManiphestTaskQuery.php
index 433a4cec7b..cf83f469ae 100644
--- a/src/applications/maniphest/query/ManiphestTaskQuery.php
+++ b/src/applications/maniphest/query/ManiphestTaskQuery.php
@@ -1,1014 +1,1008 @@
 <?php
 
 /**
  * Query tasks by specific criteria. This class uses the higher-performance
  * but less-general Maniphest indexes to satisfy queries.
  */
 final class ManiphestTaskQuery extends PhabricatorCursorPagedPolicyAwareQuery {
 
   private $taskIDs             = array();
   private $taskPHIDs           = array();
   private $authorPHIDs         = array();
   private $ownerPHIDs          = array();
   private $includeUnowned      = null;
   private $projectPHIDs        = array();
   private $xprojectPHIDs       = array();
   private $subscriberPHIDs     = array();
   private $anyProjectPHIDs     = array();
   private $anyUserProjectPHIDs = array();
   private $includeNoProject    = null;
   private $dateCreatedAfter;
   private $dateCreatedBefore;
   private $dateModifiedAfter;
   private $dateModifiedBefore;
 
   private $fullTextSearch   = '';
 
   private $status           = 'status-any';
   const STATUS_ANY          = 'status-any';
   const STATUS_OPEN         = 'status-open';
   const STATUS_CLOSED       = 'status-closed';
   const STATUS_RESOLVED     = 'status-resolved';
   const STATUS_WONTFIX      = 'status-wontfix';
   const STATUS_INVALID      = 'status-invalid';
   const STATUS_SPITE        = 'status-spite';
   const STATUS_DUPLICATE    = 'status-duplicate';
 
   private $statuses;
   private $priorities;
   private $subpriorities;
 
   private $groupBy          = 'group-none';
   const GROUP_NONE          = 'group-none';
   const GROUP_PRIORITY      = 'group-priority';
   const GROUP_OWNER         = 'group-owner';
   const GROUP_STATUS        = 'group-status';
   const GROUP_PROJECT       = 'group-project';
 
   private $orderBy          = 'order-modified';
   const ORDER_PRIORITY      = 'order-priority';
   const ORDER_CREATED       = 'order-created';
   const ORDER_MODIFIED      = 'order-modified';
   const ORDER_TITLE         = 'order-title';
 
   private $needSubscriberPHIDs;
   private $needProjectPHIDs;
   private $blockingTasks;
   private $blockedTasks;
   private $projectPolicyCheckFailed = false;
 
   public function withAuthors(array $authors) {
     $this->authorPHIDs = $authors;
     return $this;
   }
 
   public function withIDs(array $ids) {
     $this->taskIDs = $ids;
     return $this;
   }
 
   public function withPHIDs(array $phids) {
     $this->taskPHIDs = $phids;
     return $this;
   }
 
   public function withOwners(array $owners) {
     $this->includeUnowned = false;
     foreach ($owners as $k => $phid) {
       if ($phid == ManiphestTaskOwner::OWNER_UP_FOR_GRABS || $phid === null) {
         $this->includeUnowned = true;
         unset($owners[$k]);
         break;
       }
     }
     $this->ownerPHIDs = $owners;
     return $this;
   }
 
   public function withAllProjects(array $projects) {
     $this->includeNoProject = false;
     foreach ($projects as $k => $phid) {
       if ($phid == ManiphestTaskOwner::PROJECT_NO_PROJECT) {
         $this->includeNoProject = true;
         unset($projects[$k]);
       }
     }
     $this->projectPHIDs = $projects;
     return $this;
   }
 
   /**
    * Add an additional "all projects" constraint to existing filters.
    *
    * This is used by boards to supplement queries.
    *
    * @param list<phid> List of project PHIDs to add to any existing constraint.
    * @return this
    */
   public function addWithAllProjects(array $projects) {
     if ($this->projectPHIDs === null) {
       $this->projectPHIDs = array();
     }
 
     return $this->withAllProjects(array_merge($this->projectPHIDs, $projects));
   }
 
   public function withoutProjects(array $projects) {
     $this->xprojectPHIDs = $projects;
     return $this;
   }
 
   public function withStatus($status) {
     $this->status = $status;
     return $this;
   }
 
   public function withStatuses(array $statuses) {
     $this->statuses = $statuses;
     return $this;
   }
 
   public function withPriorities(array $priorities) {
     $this->priorities = $priorities;
     return $this;
   }
 
   public function withSubpriorities(array $subpriorities) {
     $this->subpriorities = $subpriorities;
     return $this;
   }
 
   public function withSubscribers(array $subscribers) {
     $this->subscriberPHIDs = $subscribers;
     return $this;
   }
 
   public function withFullTextSearch($fulltext_search) {
     $this->fullTextSearch = $fulltext_search;
     return $this;
   }
 
   public function setGroupBy($group) {
     $this->groupBy = $group;
     return $this;
   }
 
   public function setOrderBy($order) {
     $this->orderBy = $order;
     return $this;
   }
 
   public function withAnyProjects(array $projects) {
     $this->anyProjectPHIDs = $projects;
     return $this;
   }
 
   public function withAnyUserProjects(array $users) {
     $this->anyUserProjectPHIDs = $users;
     return $this;
   }
 
   /**
    * True returns tasks that are blocking other tasks only.
    * False returns tasks that are not blocking other tasks only.
    * Null returns tasks regardless of blocking status.
    */
   public function withBlockingTasks($mode) {
     $this->blockingTasks = $mode;
     return $this;
   }
 
   public function shouldJoinBlockingTasks() {
     return $this->blockingTasks !== null;
   }
 
   /**
    * True returns tasks that are blocked by other tasks only.
    * False returns tasks that are not blocked by other tasks only.
    * Null returns tasks regardless of blocked by status.
    */
   public function withBlockedTasks($mode) {
     $this->blockedTasks = $mode;
     return $this;
   }
 
   public function shouldJoinBlockedTasks() {
     return $this->blockedTasks !== null;
   }
 
   public function withDateCreatedBefore($date_created_before) {
     $this->dateCreatedBefore = $date_created_before;
     return $this;
   }
 
   public function withDateCreatedAfter($date_created_after) {
     $this->dateCreatedAfter = $date_created_after;
     return $this;
   }
 
   public function withDateModifiedBefore($date_modified_before) {
     $this->dateModifiedBefore = $date_modified_before;
     return $this;
   }
 
   public function withDateModifiedAfter($date_modified_after) {
     $this->dateModifiedAfter = $date_modified_after;
     return $this;
   }
 
   public function needSubscriberPHIDs($bool) {
     $this->needSubscriberPHIDs = $bool;
     return $this;
   }
 
   public function needProjectPHIDs($bool) {
     $this->needProjectPHIDs = $bool;
     return $this;
   }
 
   protected function newResultObject() {
     return new ManiphestTask();
   }
 
   protected function willExecute() {
     // Make sure the user can see any projects specified in this
     // query FIRST.
     if ($this->projectPHIDs) {
       $projects = id(new PhabricatorProjectQuery())
         ->setViewer($this->getViewer())
         ->withPHIDs($this->projectPHIDs)
         ->execute();
       $projects = mpull($projects, null, 'getPHID');
       foreach ($this->projectPHIDs as $index => $phid) {
         $project = idx($projects, $phid);
         if (!$project) {
           unset($this->projectPHIDs[$index]);
           continue;
         }
       }
       if (!$this->projectPHIDs) {
         $this->projectPolicyCheckFailed = true;
       }
       $this->projectPHIDs = array_values($this->projectPHIDs);
     }
 
     // If we already have an order vector, use it as provided.
     // TODO: This is a messy hack to make setOrderVector() stronger than
     // setPriority().
     $vector = $this->getOrderVector();
     $keys = mpull(iterator_to_array($vector), 'getOrderKey');
     if (array_values($keys) !== array('id')) {
       return;
     }
 
     $parts = array();
     switch ($this->groupBy) {
       case self::GROUP_NONE:
         break;
       case self::GROUP_PRIORITY:
         $parts[] = array('priority');
         break;
       case self::GROUP_OWNER:
         $parts[] = array('owner');
         break;
       case self::GROUP_STATUS:
         $parts[] = array('status');
         break;
       case self::GROUP_PROJECT:
         $parts[] = array('project');
         break;
     }
 
     if ($this->applicationSearchOrders) {
       $columns = array();
       foreach ($this->applicationSearchOrders as $order) {
         $part = 'custom:'.$order['key'];
         if ($order['ascending']) {
           $part = '-'.$part;
         }
         $columns[] = $part;
       }
       $columns[] = 'id';
       $parts[] = $columns;
     } else {
       switch ($this->orderBy) {
         case self::ORDER_PRIORITY:
           $parts[] = array('priority', 'subpriority', 'id');
           break;
         case self::ORDER_CREATED:
           $parts[] = array('id');
           break;
         case self::ORDER_MODIFIED:
           $parts[] = array('updated', 'id');
           break;
         case self::ORDER_TITLE:
           $parts[] = array('title', 'id');
           break;
       }
     }
 
     $parts = array_mergev($parts);
     // We may have a duplicate column if we are both ordering and grouping
     // by priority.
     $parts = array_unique($parts);
     $this->setOrderVector($parts);
   }
 
   protected function loadPage() {
 
     if ($this->projectPolicyCheckFailed) {
       throw new PhabricatorEmptyQueryException();
     }
 
     $task_dao = new ManiphestTask();
     $conn = $task_dao->establishConnection('r');
 
     $where = array();
     $where[] = $this->buildTaskIDsWhereClause($conn);
     $where[] = $this->buildTaskPHIDsWhereClause($conn);
     $where[] = $this->buildStatusWhereClause($conn);
     $where[] = $this->buildStatusesWhereClause($conn);
     $where[] = $this->buildDependenciesWhereClause($conn);
     $where[] = $this->buildAuthorWhereClause($conn);
     $where[] = $this->buildOwnerWhereClause($conn);
     $where[] = $this->buildProjectWhereClause($conn);
     $where[] = $this->buildAnyProjectWhereClause($conn);
     $where[] = $this->buildAnyUserProjectWhereClause($conn);
     $where[] = $this->buildXProjectWhereClause($conn);
     $where[] = $this->buildFullTextWhereClause($conn);
 
     if ($this->dateCreatedAfter) {
       $where[] = qsprintf(
         $conn,
         'task.dateCreated >= %d',
         $this->dateCreatedAfter);
     }
 
     if ($this->dateCreatedBefore) {
       $where[] = qsprintf(
         $conn,
         'task.dateCreated <= %d',
         $this->dateCreatedBefore);
     }
 
     if ($this->dateModifiedAfter) {
       $where[] = qsprintf(
         $conn,
         'task.dateModified >= %d',
         $this->dateModifiedAfter);
     }
 
     if ($this->dateModifiedBefore) {
       $where[] = qsprintf(
         $conn,
         'task.dateModified <= %d',
         $this->dateModifiedBefore);
     }
 
     if ($this->priorities) {
       $where[] = qsprintf(
         $conn,
         'task.priority IN (%Ld)',
         $this->priorities);
     }
 
     if ($this->subpriorities) {
       $where[] = qsprintf(
         $conn,
         'task.subpriority IN (%Lf)',
         $this->subpriorities);
     }
 
     $where[] = $this->buildPagingClause($conn);
 
     $where = $this->formatWhereClause($where);
 
     $having = '';
     $count = '';
 
     if (count($this->projectPHIDs) > 1) {
       // We want to treat the query as an intersection query, not a union
       // query. We sum the project count and require it be the same as the
       // number of projects we're searching for.
 
       $count = ', COUNT(project.dst) projectCount';
       $having = qsprintf(
         $conn,
         'HAVING projectCount = %d',
         count($this->projectPHIDs));
     }
 
     $group_column = '';
     switch ($this->groupBy) {
       case self::GROUP_PROJECT:
         $group_column = qsprintf(
           $conn,
           ', projectGroupName.indexedObjectPHID projectGroupPHID');
         break;
     }
 
     $rows = queryfx_all(
       $conn,
       'SELECT task.* %Q %Q FROM %T task %Q %Q %Q %Q %Q %Q',
       $count,
       $group_column,
       $task_dao->getTableName(),
       $this->buildJoinsClause($conn),
       $where,
       $this->buildGroupClause($conn),
       $having,
       $this->buildOrderClause($conn),
       $this->buildLimitClause($conn));
 
     switch ($this->groupBy) {
       case self::GROUP_PROJECT:
         $data = ipull($rows, null, 'id');
         break;
       default:
         $data = $rows;
         break;
     }
 
     $tasks = $task_dao->loadAllFromArray($data);
 
     switch ($this->groupBy) {
       case self::GROUP_PROJECT:
         $results = array();
         foreach ($rows as $row) {
           $task = clone $tasks[$row['id']];
           $task->attachGroupByProjectPHID($row['projectGroupPHID']);
           $results[] = $task;
         }
         $tasks = $results;
         break;
     }
 
     return $tasks;
   }
 
   protected function willFilterPage(array $tasks) {
     if ($this->groupBy == self::GROUP_PROJECT) {
       // We should only return project groups which the user can actually see.
       $project_phids = mpull($tasks, 'getGroupByProjectPHID');
       $projects = id(new PhabricatorProjectQuery())
         ->setViewer($this->getViewer())
         ->withPHIDs($project_phids)
         ->execute();
       $projects = mpull($projects, null, 'getPHID');
 
       foreach ($tasks as $key => $task) {
         if (!$task->getGroupByProjectPHID()) {
           // This task is either not in any projects, or only in projects
           // which we're ignoring because they're being queried for explicitly.
           continue;
         }
 
         if (empty($projects[$task->getGroupByProjectPHID()])) {
           unset($tasks[$key]);
         }
       }
     }
 
     return $tasks;
   }
 
   protected function didFilterPage(array $tasks) {
     $phids = mpull($tasks, 'getPHID');
 
     if ($this->needProjectPHIDs) {
       $edge_query = id(new PhabricatorEdgeQuery())
         ->withSourcePHIDs($phids)
         ->withEdgeTypes(
           array(
             PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
           ));
       $edge_query->execute();
 
       foreach ($tasks as $task) {
         $project_phids = $edge_query->getDestinationPHIDs(
           array($task->getPHID()));
         $task->attachProjectPHIDs($project_phids);
       }
     }
 
     if ($this->needSubscriberPHIDs) {
       $subscriber_sets = id(new PhabricatorSubscribersQuery())
         ->withObjectPHIDs($phids)
         ->execute();
       foreach ($tasks as $task) {
         $subscribers = idx($subscriber_sets, $task->getPHID(), array());
         $task->attachSubscriberPHIDs($subscribers);
       }
     }
 
     return $tasks;
   }
 
   private function buildTaskIDsWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->taskIDs) {
       return null;
     }
 
     return qsprintf(
       $conn,
       'task.id in (%Ld)',
       $this->taskIDs);
   }
 
   private function buildTaskPHIDsWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->taskPHIDs) {
       return null;
     }
 
     return qsprintf(
       $conn,
       'task.phid in (%Ls)',
       $this->taskPHIDs);
   }
 
   private function buildStatusWhereClause(AphrontDatabaseConnection $conn) {
     static $map = array(
       self::STATUS_RESOLVED   => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED,
       self::STATUS_WONTFIX    => ManiphestTaskStatus::STATUS_CLOSED_WONTFIX,
       self::STATUS_INVALID    => ManiphestTaskStatus::STATUS_CLOSED_INVALID,
       self::STATUS_SPITE      => ManiphestTaskStatus::STATUS_CLOSED_SPITE,
       self::STATUS_DUPLICATE  => ManiphestTaskStatus::STATUS_CLOSED_DUPLICATE,
     );
 
     switch ($this->status) {
       case self::STATUS_ANY:
         return null;
       case self::STATUS_OPEN:
         return qsprintf(
           $conn,
           'task.status IN (%Ls)',
           ManiphestTaskStatus::getOpenStatusConstants());
       case self::STATUS_CLOSED:
         return qsprintf(
           $conn,
           'task.status IN (%Ls)',
           ManiphestTaskStatus::getClosedStatusConstants());
       default:
         $constant = idx($map, $this->status);
         if (!$constant) {
           throw new Exception("Unknown status query '{$this->status}'!");
         }
         return qsprintf(
           $conn,
           'task.status = %s',
           $constant);
     }
   }
 
   private function buildStatusesWhereClause(AphrontDatabaseConnection $conn) {
     if ($this->statuses) {
       return qsprintf(
         $conn,
         'task.status IN (%Ls)',
         $this->statuses);
     }
     return null;
   }
 
   private function buildAuthorWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->authorPHIDs) {
       return null;
     }
 
     return qsprintf(
       $conn,
       'task.authorPHID in (%Ls)',
       $this->authorPHIDs);
   }
 
   private function buildOwnerWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->ownerPHIDs) {
       if ($this->includeUnowned === null) {
         return null;
       } else if ($this->includeUnowned) {
         return qsprintf(
           $conn,
           'task.ownerPHID IS NULL');
       } else {
         return qsprintf(
           $conn,
           'task.ownerPHID IS NOT NULL');
       }
     }
 
     if ($this->includeUnowned) {
       return qsprintf(
         $conn,
         'task.ownerPHID IN (%Ls) OR task.ownerPHID IS NULL',
         $this->ownerPHIDs);
     } else {
       return qsprintf(
         $conn,
         'task.ownerPHID IN (%Ls)',
         $this->ownerPHIDs);
     }
   }
 
   private function buildFullTextWhereClause(AphrontDatabaseConnection $conn) {
     if (!strlen($this->fullTextSearch)) {
       return null;
     }
 
     // In doing a fulltext search, we first find all the PHIDs that match the
     // fulltext search, and then use that to limit the rest of the search
     $fulltext_query = id(new PhabricatorSavedQuery())
       ->setEngineClassName('PhabricatorSearchApplicationSearchEngine')
       ->setParameter('query', $this->fullTextSearch);
 
     // NOTE: Setting this to something larger than 2^53 will raise errors in
     // ElasticSearch, and billions of results won't fit in memory anyway.
     $fulltext_query->setParameter('limit', 100000);
     $fulltext_query->setParameter('type', ManiphestTaskPHIDType::TYPECONST);
 
     $engine = PhabricatorSearchEngineSelector::newSelector()->newEngine();
     $fulltext_results = $engine->executeSearch($fulltext_query);
 
     if (empty($fulltext_results)) {
       $fulltext_results = array(null);
     }
 
     return qsprintf(
       $conn,
       'task.phid IN (%Ls)',
       $fulltext_results);
   }
 
   private function buildDependenciesWhereClause(
     AphrontDatabaseConnection $conn) {
 
     if (!$this->shouldJoinBlockedTasks() &&
         !$this->shouldJoinBlockingTasks()) {
       return null;
     }
 
     $parts = array();
     if ($this->blockingTasks === true) {
       $parts[] = qsprintf(
         $conn,
         'blocking.dst IS NOT NULL AND blockingtask.status IN (%Ls)',
         ManiphestTaskStatus::getOpenStatusConstants());
     } else if ($this->blockingTasks === false) {
       $parts[] = qsprintf(
         $conn,
         'blocking.dst IS NULL OR blockingtask.status NOT IN (%Ls)',
         ManiphestTaskStatus::getOpenStatusConstants());
     }
 
     if ($this->blockedTasks === true) {
       $parts[] = qsprintf(
         $conn,
         'blocked.dst IS NOT NULL AND blockedtask.status IN (%Ls)',
         ManiphestTaskStatus::getOpenStatusConstants());
     } else if ($this->blockedTasks === false) {
       $parts[] = qsprintf(
         $conn,
         'blocked.dst IS NULL OR blockedtask.status NOT IN (%Ls)',
         ManiphestTaskStatus::getOpenStatusConstants());
     }
 
     return '('.implode(') OR (', $parts).')';
   }
 
   private function buildProjectWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->projectPHIDs && !$this->includeNoProject) {
       return null;
     }
 
     $parts = array();
     if ($this->projectPHIDs) {
       $parts[] = qsprintf(
         $conn,
         'project.dst in (%Ls)',
         $this->projectPHIDs);
     }
     if ($this->includeNoProject) {
       $parts[] = qsprintf(
         $conn,
         'project.dst IS NULL');
     }
 
     return '('.implode(') OR (', $parts).')';
   }
 
   private function buildAnyProjectWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->anyProjectPHIDs) {
       return null;
     }
 
     return qsprintf(
       $conn,
       'anyproject.dst IN (%Ls)',
       $this->anyProjectPHIDs);
   }
 
   private function buildAnyUserProjectWhereClause(
     AphrontDatabaseConnection $conn) {
     if (!$this->anyUserProjectPHIDs) {
       return null;
     }
 
     $projects = id(new PhabricatorProjectQuery())
       ->setViewer($this->getViewer())
       ->withMemberPHIDs($this->anyUserProjectPHIDs)
       ->execute();
     $any_user_project_phids = mpull($projects, 'getPHID');
     if (!$any_user_project_phids) {
       throw new PhabricatorEmptyQueryException();
     }
 
     return qsprintf(
       $conn,
       'anyproject.dst IN (%Ls)',
       $any_user_project_phids);
   }
 
   private function buildXProjectWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->xprojectPHIDs) {
       return null;
     }
 
     return qsprintf(
       $conn,
       'xproject.dst IS NULL');
   }
 
   private function buildJoinsClause(AphrontDatabaseConnection $conn_r) {
     $edge_table = PhabricatorEdgeConfig::TABLE_NAME_EDGE;
 
     $joins = array();
 
     if ($this->projectPHIDs || $this->includeNoProject) {
       $joins[] = qsprintf(
         $conn_r,
         '%Q JOIN %T project ON project.src = task.phid
           AND project.type = %d',
         ($this->includeNoProject ? 'LEFT' : ''),
         $edge_table,
         PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
     }
 
     if ($this->shouldJoinBlockingTasks()) {
       $joins[] = qsprintf(
         $conn_r,
         'LEFT JOIN %T blocking ON blocking.src = task.phid '.
         'AND blocking.type = %d '.
         'LEFT JOIN %T blockingtask ON blocking.dst = blockingtask.phid',
         $edge_table,
         ManiphestTaskDependedOnByTaskEdgeType::EDGECONST,
         id(new ManiphestTask())->getTableName());
     }
     if ($this->shouldJoinBlockedTasks()) {
       $joins[] = qsprintf(
         $conn_r,
         'LEFT JOIN %T blocked ON blocked.src = task.phid '.
         'AND blocked.type = %d '.
         'LEFT JOIN %T blockedtask ON blocked.dst = blockedtask.phid',
         $edge_table,
         ManiphestTaskDependsOnTaskEdgeType::EDGECONST,
         id(new ManiphestTask())->getTableName());
     }
 
     if ($this->anyProjectPHIDs || $this->anyUserProjectPHIDs) {
       $joins[] = qsprintf(
         $conn_r,
         'JOIN %T anyproject ON anyproject.src = task.phid
           AND anyproject.type = %d',
         $edge_table,
         PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
     }
 
     if ($this->xprojectPHIDs) {
       $joins[] = qsprintf(
         $conn_r,
         'LEFT JOIN %T xproject ON xproject.src = task.phid
           AND xproject.type = %d
           AND xproject.dst IN (%Ls)',
         $edge_table,
         PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
         $this->xprojectPHIDs);
     }
 
     if ($this->subscriberPHIDs) {
       $joins[] = qsprintf(
         $conn_r,
         'JOIN %T e_ccs ON e_ccs.src = task.phid '.
         'AND e_ccs.type = %s '.
         'AND e_ccs.dst in (%Ls)',
         PhabricatorEdgeConfig::TABLE_NAME_EDGE,
         PhabricatorObjectHasSubscriberEdgeType::EDGECONST,
         $this->subscriberPHIDs);
     }
 
     switch ($this->groupBy) {
       case self::GROUP_PROJECT:
         $ignore_group_phids = $this->getIgnoreGroupedProjectPHIDs();
         if ($ignore_group_phids) {
           $joins[] = qsprintf(
             $conn_r,
             'LEFT JOIN %T projectGroup ON task.phid = projectGroup.src
               AND projectGroup.type = %d
               AND projectGroup.dst NOT IN (%Ls)',
             $edge_table,
             PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
             $ignore_group_phids);
         } else {
           $joins[] = qsprintf(
             $conn_r,
             'LEFT JOIN %T projectGroup ON task.phid = projectGroup.src
               AND projectGroup.type = %d',
             $edge_table,
             PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
         }
         $joins[] = qsprintf(
           $conn_r,
           'LEFT JOIN %T projectGroupName
             ON projectGroup.dst = projectGroupName.indexedObjectPHID',
           id(new ManiphestNameIndex())->getTableName());
         break;
     }
 
     $joins[] = $this->buildApplicationSearchJoinClause($conn_r);
 
     return implode(' ', $joins);
   }
 
   private function buildGroupClause(AphrontDatabaseConnection $conn_r) {
     $joined_multiple_rows = (count($this->projectPHIDs) > 1) ||
                             (count($this->anyProjectPHIDs) > 1) ||
                             $this->shouldJoinBlockingTasks() ||
                             $this->shouldJoinBlockedTasks() ||
                             ($this->getApplicationSearchMayJoinMultipleRows());
 
     $joined_project_name = ($this->groupBy == self::GROUP_PROJECT);
 
     // If we're joining multiple rows, we need to group the results by the
     // task IDs.
     if ($joined_multiple_rows) {
       if ($joined_project_name) {
         return 'GROUP BY task.phid, projectGroup.dst';
       } else {
         return 'GROUP BY task.phid';
       }
     } else {
       return '';
     }
   }
 
   /**
    * Return project PHIDs which we should ignore when grouping tasks by
    * project. For example, if a user issues a query like:
    *
    *   Tasks in all projects: Frontend, Bugs
    *
    * ...then we don't show "Frontend" or "Bugs" groups in the result set, since
    * they're meaningless as all results are in both groups.
    *
    * Similarly, for queries like:
    *
    *   Tasks in any projects: Public Relations
    *
    * ...we ignore the single project, as every result is in that project. (In
    * the case that there are several "any" projects, we do not ignore them.)
    *
    * @return list<phid> Project PHIDs which should be ignored in query
    *                    construction.
    */
   private function getIgnoreGroupedProjectPHIDs() {
     $phids = array();
 
     if ($this->projectPHIDs) {
       $phids[] = $this->projectPHIDs;
     }
 
     if (count($this->anyProjectPHIDs) == 1) {
       $phids[] = $this->anyProjectPHIDs;
     }
 
     // Maybe we should also exclude the "excludeProjectPHIDs"? It won't
     // impact the results, but we might end up with a better query plan.
     // Investigate this on real data? This is likely very rare.
 
     return array_mergev($phids);
   }
 
-  protected function getPagingValue($result) {
+  protected function getResultCursor($result) {
     $id = $result->getID();
 
-    switch ($this->groupBy) {
-      case self::GROUP_NONE:
-      case self::GROUP_STATUS:
-      case self::GROUP_PRIORITY:
-      case self::GROUP_OWNER:
-        return $id;
-      case self::GROUP_PROJECT:
-        return rtrim($id.'.'.$result->getGroupByProjectPHID(), '.');
-      default:
-        throw new Exception("Unknown group query '{$this->groupBy}'!");
+    if ($this->groupBy == self::GROUP_PROJECT) {
+      return rtrim($id.'.'.$result->getGroupByProjectPHID(), '.');;
     }
+
+    return $id;
   }
 
   public function getOrderableColumns() {
     return parent::getOrderableColumns() + array(
       'priority' => array(
         'table' => 'task',
         'column' => 'priority',
         'type' => 'int',
       ),
       'owner' => array(
         'table' => 'task',
         'column' => 'ownerOrdering',
         'null' => 'head',
         'reverse' => true,
         'type' => 'string',
       ),
       'status' => array(
         'table' => 'task',
         'column' => 'status',
         'type' => 'string',
         'reverse' => true,
       ),
       'project' => array(
         'table' => 'projectGroupName',
         'column' => 'indexedObjectName',
         'type' => 'string',
         'null' => 'head',
         'reverse' => true,
       ),
       'title' => array(
         'table' => 'task',
         'column' => 'title',
         'type' => 'string',
         'reverse' => true,
       ),
       'subpriority' => array(
         'table' => 'task',
         'column' => 'subpriority',
         'type' => 'float',
       ),
       'updated' => array(
         'table' => 'task',
         'column' => 'dateModified',
         'type' => 'int',
       ),
     );
   }
 
   protected function getPagingValueMap($cursor, array $keys) {
     $cursor_parts = explode('.', $cursor, 2);
     $task_id = $cursor_parts[0];
     $group_id = idx($cursor_parts, 1);
 
     $task = $this->loadCursorObject($task_id);
 
     $map = array(
       'id' => $task->getID(),
       'priority' => $task->getPriority(),
       'subpriority' => $task->getSubpriority(),
       'owner' => $task->getOwnerOrdering(),
       'status' => $task->getStatus(),
       'title' => $task->getTitle(),
       'updated' => $task->getDateModified(),
     );
 
     foreach ($keys as $key) {
       switch ($key) {
         case 'project':
           $value = null;
           if ($group_id) {
             $paging_projects = id(new PhabricatorProjectQuery())
               ->setViewer($this->getViewer())
               ->withPHIDs(array($group_id))
               ->execute();
             if ($paging_projects) {
               $value = head($paging_projects)->getName();
             }
           }
           $map[$key] = $value;
           break;
       }
     }
 
     foreach ($keys as $key) {
       if ($this->isCustomFieldOrderKey($key)) {
         $map += $this->getPagingValueMapForCustomFields($task);
         break;
       }
     }
 
     return $map;
   }
 
   protected function getPrimaryTableAlias() {
     return 'task';
   }
 
   public function getQueryApplicationClass() {
     return 'PhabricatorManiphestApplication';
   }
 
 }
diff --git a/src/applications/notification/query/PhabricatorNotificationQuery.php b/src/applications/notification/query/PhabricatorNotificationQuery.php
index 263aea04db..a1f8ac63c7 100644
--- a/src/applications/notification/query/PhabricatorNotificationQuery.php
+++ b/src/applications/notification/query/PhabricatorNotificationQuery.php
@@ -1,114 +1,114 @@
 <?php
 
 /**
  * @task config Configuring the Query
  * @task exec   Query Execution
  */
 final class PhabricatorNotificationQuery
   extends PhabricatorCursorPagedPolicyAwareQuery {
 
   private $userPHIDs;
   private $keys;
   private $unread;
 
 
 /* -(  Configuring the Query  )---------------------------------------------- */
 
 
   public function withUserPHIDs(array $user_phids) {
     $this->userPHIDs = $user_phids;
     return $this;
   }
 
   public function withKeys(array $keys) {
     $this->keys = $keys;
     return $this;
   }
 
 
   /**
    * Filter results by read/unread status. Note that `true` means to return
    * only unread notifications, while `false` means to return only //read//
    * notifications. The default is `null`, which returns both.
    *
    * @param mixed True or false to filter results by read status. Null to remove
    *              the filter.
    * @return this
    * @task config
    */
   public function withUnread($unread) {
     $this->unread = $unread;
     return $this;
   }
 
 
 /* -(  Query Execution  )---------------------------------------------------- */
 
 
   protected function loadPage() {
     $story_table = new PhabricatorFeedStoryData();
     $notification_table = new PhabricatorFeedStoryNotification();
 
     $conn = $story_table->establishConnection('r');
 
     $data = queryfx_all(
       $conn,
       'SELECT story.*, notif.hasViewed FROM %T notif
          JOIN %T story ON notif.chronologicalKey = story.chronologicalKey
          %Q
          ORDER BY notif.chronologicalKey DESC
          %Q',
       $notification_table->getTableName(),
       $story_table->getTableName(),
       $this->buildWhereClause($conn),
       $this->buildLimitClause($conn));
 
     $viewed_map = ipull($data, 'hasViewed', 'chronologicalKey');
 
     $stories = PhabricatorFeedStory::loadAllFromRows(
       $data,
       $this->getViewer());
 
     foreach ($stories as $key => $story) {
       $story->setHasViewed($viewed_map[$key]);
     }
 
     return $stories;
   }
 
   private function buildWhereClause(AphrontDatabaseConnection $conn_r) {
     $where = array();
 
     if ($this->userPHIDs !== null) {
       $where[] = qsprintf(
         $conn_r,
         'notif.userPHID IN (%Ls)',
         $this->userPHIDs);
     }
 
     if ($this->unread !== null) {
       $where[] = qsprintf(
         $conn_r,
         'notif.hasViewed = %d',
         (int)!$this->unread);
     }
 
     if ($this->keys) {
       $where[] = qsprintf(
         $conn_r,
         'notif.chronologicalKey IN (%Ls)',
         $this->keys);
     }
 
     return $this->formatWhereClause($where);
   }
 
-  protected function getPagingValue($item) {
+  protected function getResultCursor($item) {
     return $item->getChronologicalKey();
   }
 
   public function getQueryApplicationClass() {
     return 'PhabricatorNotificationsApplication';
   }
 
 }
diff --git a/src/applications/project/query/PhabricatorProjectQuery.php b/src/applications/project/query/PhabricatorProjectQuery.php
index 89d1f31dba..1dfc466712 100644
--- a/src/applications/project/query/PhabricatorProjectQuery.php
+++ b/src/applications/project/query/PhabricatorProjectQuery.php
@@ -1,401 +1,398 @@
 <?php
 
 final class PhabricatorProjectQuery
   extends PhabricatorCursorPagedPolicyAwareQuery {
 
   private $ids;
   private $phids;
   private $memberPHIDs;
   private $slugs;
   private $phrictionSlugs;
   private $names;
   private $datasourceQuery;
   private $icons;
   private $colors;
 
   private $status       = 'status-any';
   const STATUS_ANY      = 'status-any';
   const STATUS_OPEN     = 'status-open';
   const STATUS_CLOSED   = 'status-closed';
   const STATUS_ACTIVE   = 'status-active';
   const STATUS_ARCHIVED = 'status-archived';
 
   private $needSlugs;
   private $needMembers;
   private $needWatchers;
   private $needImages;
 
   public function withIDs(array $ids) {
     $this->ids = $ids;
     return $this;
   }
 
   public function withPHIDs(array $phids) {
     $this->phids = $phids;
     return $this;
   }
 
   public function withStatus($status) {
     $this->status = $status;
     return $this;
   }
 
   public function withMemberPHIDs(array $member_phids) {
     $this->memberPHIDs = $member_phids;
     return $this;
   }
 
   public function withSlugs(array $slugs) {
     $this->slugs = $slugs;
     return $this;
   }
 
   public function withPhrictionSlugs(array $slugs) {
     $this->phrictionSlugs = $slugs;
     return $this;
   }
 
   public function withNames(array $names) {
     $this->names = $names;
     return $this;
   }
 
   public function withDatasourceQuery($string) {
     $this->datasourceQuery = $string;
     return $this;
   }
 
   public function withIcons(array $icons) {
     $this->icons = $icons;
     return $this;
   }
 
   public function withColors(array $colors) {
     $this->colors = $colors;
     return $this;
   }
 
   public function needMembers($need_members) {
     $this->needMembers = $need_members;
     return $this;
   }
 
   public function needWatchers($need_watchers) {
     $this->needWatchers = $need_watchers;
     return $this;
   }
 
   public function needImages($need_images) {
     $this->needImages = $need_images;
     return $this;
   }
 
   public function needSlugs($need_slugs) {
     $this->needSlugs = $need_slugs;
     return $this;
   }
 
   protected function getDefaultOrderVector() {
     return array('name');
   }
 
   public function getOrderableColumns() {
     return array(
       'name' => array(
         'table' => $this->getPrimaryTableAlias(),
         'column' => 'name',
         'reverse' => true,
         'type' => 'string',
         'unique' => true,
       ),
     );
   }
 
   protected function getPagingValueMap($cursor, array $keys) {
+    $project = $this->loadCursorObject($cursor);
     return array(
-      'name' => $cursor,
+      'name' => $project->getName(),
     );
   }
 
-  protected function getPagingValue($result) {
-    return $result->getName();
-  }
-
   protected function loadPage() {
     $table = new PhabricatorProject();
     $conn_r = $table->establishConnection('r');
 
     // NOTE: Because visibility checks for projects depend on whether or not
     // the user is a project member, we always load their membership. If we're
     // loading all members anyway we can piggyback on that; otherwise we
     // do an explicit join.
 
     $select_clause = '';
     if (!$this->needMembers) {
       $select_clause = ', vm.dst viewerIsMember';
     }
 
     $data = queryfx_all(
       $conn_r,
       'SELECT p.* %Q FROM %T p %Q %Q %Q %Q %Q',
       $select_clause,
       $table->getTableName(),
       $this->buildJoinClause($conn_r),
       $this->buildWhereClause($conn_r),
       $this->buildGroupClause($conn_r),
       $this->buildOrderClause($conn_r),
       $this->buildLimitClause($conn_r));
 
     $projects = $table->loadAllFromArray($data);
 
     if ($projects) {
       $viewer_phid = $this->getViewer()->getPHID();
       $project_phids = mpull($projects, 'getPHID');
 
       $member_type = PhabricatorProjectProjectHasMemberEdgeType::EDGECONST;
       $watcher_type = PhabricatorObjectHasWatcherEdgeType::EDGECONST;
 
       $need_edge_types = array();
       if ($this->needMembers) {
         $need_edge_types[] = $member_type;
       } else {
         foreach ($data as $row) {
           $projects[$row['id']]->setIsUserMember(
             $viewer_phid,
             ($row['viewerIsMember'] !== null));
         }
       }
 
       if ($this->needWatchers) {
         $need_edge_types[] = $watcher_type;
       }
 
       if ($need_edge_types) {
         $edges = id(new PhabricatorEdgeQuery())
           ->withSourcePHIDs($project_phids)
           ->withEdgeTypes($need_edge_types)
           ->execute();
 
         if ($this->needMembers) {
           foreach ($projects as $project) {
             $phid = $project->getPHID();
             $project->attachMemberPHIDs(
               array_keys($edges[$phid][$member_type]));
             $project->setIsUserMember(
               $viewer_phid,
               isset($edges[$phid][$member_type][$viewer_phid]));
           }
         }
 
         if ($this->needWatchers) {
           foreach ($projects as $project) {
             $phid = $project->getPHID();
             $project->attachWatcherPHIDs(
               array_keys($edges[$phid][$watcher_type]));
             $project->setIsUserWatcher(
               $viewer_phid,
               isset($edges[$phid][$watcher_type][$viewer_phid]));
           }
         }
       }
     }
 
     return $projects;
   }
 
   protected function didFilterPage(array $projects) {
     if ($this->needImages) {
       $default = null;
 
       $file_phids = mpull($projects, 'getProfileImagePHID');
       $files = id(new PhabricatorFileQuery())
         ->setParentQuery($this)
         ->setViewer($this->getViewer())
         ->withPHIDs($file_phids)
         ->execute();
       $files = mpull($files, null, 'getPHID');
       foreach ($projects as $project) {
         $file = idx($files, $project->getProfileImagePHID());
         if (!$file) {
           if (!$default) {
             $default = PhabricatorFile::loadBuiltin(
               $this->getViewer(),
               'project.png');
           }
           $file = $default;
         }
         $project->attachProfileImageFile($file);
       }
     }
 
     if ($this->needSlugs) {
       $slugs = id(new PhabricatorProjectSlug())
         ->loadAllWhere(
           'projectPHID IN (%Ls)',
           mpull($projects, 'getPHID'));
       $slugs = mgroup($slugs, 'getProjectPHID');
       foreach ($projects as $project) {
         $project_slugs = idx($slugs, $project->getPHID(), array());
         $project->attachSlugs($project_slugs);
       }
     }
 
     return $projects;
   }
 
   private function buildWhereClause($conn_r) {
     $where = array();
 
     if ($this->status != self::STATUS_ANY) {
       switch ($this->status) {
         case self::STATUS_OPEN:
         case self::STATUS_ACTIVE:
           $filter = array(
             PhabricatorProjectStatus::STATUS_ACTIVE,
           );
           break;
         case self::STATUS_CLOSED:
         case self::STATUS_ARCHIVED:
           $filter = array(
             PhabricatorProjectStatus::STATUS_ARCHIVED,
           );
           break;
         default:
           throw new Exception(
             "Unknown project status '{$this->status}'!");
       }
       $where[] = qsprintf(
         $conn_r,
         'status IN (%Ld)',
         $filter);
     }
 
     if ($this->ids !== null) {
       $where[] = qsprintf(
         $conn_r,
         'id IN (%Ld)',
         $this->ids);
     }
 
     if ($this->phids !== null) {
       $where[] = qsprintf(
         $conn_r,
         'phid IN (%Ls)',
         $this->phids);
     }
 
     if ($this->memberPHIDs !== null) {
       $where[] = qsprintf(
         $conn_r,
         'e.dst IN (%Ls)',
         $this->memberPHIDs);
     }
 
     if ($this->slugs !== null) {
       $where[] = qsprintf(
         $conn_r,
         'slug.slug IN (%Ls)',
         $this->slugs);
     }
 
     if ($this->phrictionSlugs !== null) {
       $where[] = qsprintf(
         $conn_r,
         'phrictionSlug IN (%Ls)',
         $this->phrictionSlugs);
     }
 
     if ($this->names !== null) {
       $where[] = qsprintf(
         $conn_r,
         'name IN (%Ls)',
         $this->names);
     }
 
     if ($this->icons !== null) {
       $where[] = qsprintf(
         $conn_r,
         'icon IN (%Ls)',
         $this->icons);
     }
 
     if ($this->colors !== null) {
       $where[] = qsprintf(
         $conn_r,
         'color IN (%Ls)',
         $this->colors);
     }
 
     $where[] = $this->buildPagingClause($conn_r);
 
     return $this->formatWhereClause($where);
   }
 
   private function buildGroupClause($conn_r) {
     if ($this->memberPHIDs || $this->datasourceQuery) {
       return 'GROUP BY p.id';
     } else {
       return $this->buildApplicationSearchGroupClause($conn_r);
     }
   }
 
   private function buildJoinClause($conn_r) {
     $joins = array();
 
     if (!$this->needMembers !== null) {
       $joins[] = qsprintf(
         $conn_r,
         'LEFT JOIN %T vm ON vm.src = p.phid AND vm.type = %d AND vm.dst = %s',
         PhabricatorEdgeConfig::TABLE_NAME_EDGE,
         PhabricatorProjectProjectHasMemberEdgeType::EDGECONST,
         $this->getViewer()->getPHID());
     }
 
     if ($this->memberPHIDs !== null) {
       $joins[] = qsprintf(
         $conn_r,
         'JOIN %T e ON e.src = p.phid AND e.type = %d',
         PhabricatorEdgeConfig::TABLE_NAME_EDGE,
         PhabricatorProjectProjectHasMemberEdgeType::EDGECONST);
     }
 
     if ($this->slugs !== null) {
       $joins[] = qsprintf(
         $conn_r,
         'JOIN %T slug on slug.projectPHID = p.phid',
         id(new PhabricatorProjectSlug())->getTableName());
     }
 
     if ($this->datasourceQuery !== null) {
       $tokens = PhabricatorTypeaheadDatasource::tokenizeString(
         $this->datasourceQuery);
       if (!$tokens) {
         throw new PhabricatorEmptyQueryException();
       }
 
       $likes = array();
       foreach ($tokens as $token) {
         $likes[] = qsprintf($conn_r, 'token.token LIKE %>', $token);
       }
 
       $joins[] = qsprintf(
         $conn_r,
         'JOIN %T token ON token.projectID = p.id AND (%Q)',
         PhabricatorProject::TABLE_DATASOURCE_TOKEN,
         '('.implode(') OR (', $likes).')');
     }
 
     $joins[] = $this->buildApplicationSearchJoinClause($conn_r);
 
     return implode(' ', $joins);
   }
 
   public function getQueryApplicationClass() {
     return 'PhabricatorProjectApplication';
   }
 
   protected function getPrimaryTableAlias() {
     return 'p';
   }
 
 }
diff --git a/src/applications/search/query/PhabricatorSearchDocumentQuery.php b/src/applications/search/query/PhabricatorSearchDocumentQuery.php
index aa69fc1783..b5591d8eca 100644
--- a/src/applications/search/query/PhabricatorSearchDocumentQuery.php
+++ b/src/applications/search/query/PhabricatorSearchDocumentQuery.php
@@ -1,84 +1,84 @@
 <?php
 
 final class PhabricatorSearchDocumentQuery
   extends PhabricatorCursorPagedPolicyAwareQuery {
 
   private $savedQuery;
 
   public function withSavedQuery(PhabricatorSavedQuery $query) {
     $this->savedQuery = $query;
     return $this;
   }
 
   protected function loadPage() {
     $phids = $this->loadDocumentPHIDsWithoutPolicyChecks();
 
     $handles = id(new PhabricatorHandleQuery())
       ->setViewer($this->getViewer())
       ->withPHIDs($phids)
       ->execute();
 
     // Retain engine order.
     $handles = array_select_keys($handles, $phids);
 
     return $handles;
   }
 
   protected function willFilterPage(array $handles) {
     // NOTE: This is used by the object selector dialog to exclude the object
     // you're looking at, so that, e.g., a task can't be set as a dependency
     // of itself in the UI.
 
     // TODO: Remove this after object selection moves to ApplicationSearch.
 
     $exclude = array();
     if ($this->savedQuery) {
       $exclude_phids = $this->savedQuery->getParameter('excludePHIDs', array());
       $exclude = array_fuse($exclude_phids);
     }
 
     foreach ($handles as $key => $handle) {
       if (!$handle->isComplete()) {
         unset($handles[$key]);
         continue;
       }
       if ($handle->getPolicyFiltered()) {
         unset($handles[$key]);
         continue;
       }
       if (isset($exclude[$handle->getPHID()])) {
         unset($handles[$key]);
         continue;
       }
     }
 
     return $handles;
   }
 
   public function loadDocumentPHIDsWithoutPolicyChecks() {
     $query = id(clone($this->savedQuery))
       ->setParameter('offset', $this->getOffset())
       ->setParameter('limit', $this->getRawResultLimit());
 
     $engine = PhabricatorSearchEngineSelector::newSelector()->newEngine();
 
     return $engine->executeSearch($query);
   }
 
   public function getQueryApplicationClass() {
     return 'PhabricatorSearchApplication';
   }
 
-  protected function getPagingValue($result) {
+  protected function getResultCursor($result) {
     throw new Exception(
       pht(
         'This query does not support cursor paging; it must be offset '.
         'paged.'));
   }
 
   protected function nextPage(array $page) {
     $this->setOffset($this->getOffset() + count($page));
     return $this;
   }
 
 }
diff --git a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
index 068229c9ef..f0cff78e7c 100644
--- a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
+++ b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
@@ -1,1112 +1,1128 @@
 <?php
 
 /**
  * A query class which uses cursor-based paging. This paging is much more
  * performant than offset-based paging in the presence of policy filtering.
  *
  * @task appsearch Integration with ApplicationSearch
  * @task paging Paging
  * @task order Result Ordering
  */
 abstract class PhabricatorCursorPagedPolicyAwareQuery
   extends PhabricatorPolicyAwareQuery {
 
   private $afterID;
   private $beforeID;
   private $applicationSearchConstraints = array();
   protected $applicationSearchOrders = array();
   private $internalPaging;
   private $orderVector;
   private $builtinOrder;
 
-  protected function getPagingValue($result) {
-    if (!is_object($result)) {
-      // This interface can't be typehinted and PHP gets really angry if we
-      // call a method on a non-object, so add an explicit check here.
-      throw new Exception(pht('Expected object, got "%s"!', gettype($result)));
+  protected function getPageCursors(array $page) {
+    return array(
+      $this->getResultCursor(head($page)),
+      $this->getResultCursor(last($page)),
+    );
+  }
+
+  protected function getResultCursor($object) {
+    if (!is_object($object)) {
+      throw new Exception(
+        pht(
+          'Expected object, got "%s".',
+          gettype($object)));
     }
-    return $result->getID();
+
+    return $object->getID();
   }
 
   protected function nextPage(array $page) {
     // See getPagingViewer() for a description of this flag.
     $this->internalPaging = true;
 
-    if ($this->beforeID) {
-      $this->beforeID = $this->getPagingValue(last($page));
+    if ($this->beforeID !== null) {
+      $page = array_reverse($page, $preserve_keys = true);
+      list($before, $after) = $this->getPageCursors($page);
+      $this->beforeID = $before;
     } else {
-      $this->afterID = $this->getPagingValue(last($page));
+      list($before, $after) = $this->getPageCursors($page);
+      $this->afterID = $after;
     }
   }
 
   final public function setAfterID($object_id) {
     $this->afterID = $object_id;
     return $this;
   }
 
   final protected function getAfterID() {
     return $this->afterID;
   }
 
   final public function setBeforeID($object_id) {
     $this->beforeID = $object_id;
     return $this;
   }
 
   final protected function getBeforeID() {
     return $this->beforeID;
   }
 
 
   /**
    * Get the viewer for making cursor paging queries.
    *
    * NOTE: You should ONLY use this viewer to load cursor objects while
    * building paging queries.
    *
    * Cursor paging can happen in two ways. First, the user can request a page
    * like `/stuff/?after=33`, which explicitly causes paging. Otherwise, we
    * can fall back to implicit paging if we filter some results out of a
    * result list because the user can't see them and need to go fetch some more
    * results to generate a large enough result list.
    *
    * In the first case, want to use the viewer's policies to load the object.
    * This prevents an attacker from figuring out information about an object
    * they can't see by executing queries like `/stuff/?after=33&order=name`,
    * which would otherwise give them a hint about the name of the object.
    * Generally, if a user can't see an object, they can't use it to page.
    *
    * In the second case, we need to load the object whether the user can see
    * it or not, because we need to examine new results. For example, if a user
    * loads `/stuff/` and we run a query for the first 100 items that they can
    * see, but the first 100 rows in the database aren't visible, we need to
    * be able to issue a query for the next 100 results. If we can't load the
    * cursor object, we'll fail or issue the same query over and over again.
    * So, generally, internal paging must bypass policy controls.
    *
    * This method returns the appropriate viewer, based on the context in which
    * the paging is occuring.
    *
    * @return PhabricatorUser Viewer for executing paging queries.
    */
   final protected function getPagingViewer() {
     if ($this->internalPaging) {
       return PhabricatorUser::getOmnipotentUser();
     } else {
       return $this->getViewer();
     }
   }
 
   final protected function buildLimitClause(AphrontDatabaseConnection $conn_r) {
     if ($this->getRawResultLimit()) {
       return qsprintf($conn_r, 'LIMIT %d', $this->getRawResultLimit());
     } else {
       return '';
     }
   }
 
   final protected function didLoadResults(array $results) {
     if ($this->beforeID) {
       $results = array_reverse($results, $preserve_keys = true);
     }
     return $results;
   }
 
   final public function executeWithCursorPager(AphrontCursorPagerView $pager) {
-    $this->setLimit($pager->getPageSize() + 1);
+    $limit = $pager->getPageSize();
+
+    $this->setLimit($limit + 1);
 
     if ($pager->getAfterID()) {
       $this->setAfterID($pager->getAfterID());
     } else if ($pager->getBeforeID()) {
       $this->setBeforeID($pager->getBeforeID());
     }
 
     $results = $this->execute();
+    $count = count($results);
 
     $sliced_results = $pager->sliceResults($results);
-
     if ($sliced_results) {
-      if ($pager->getBeforeID() || (count($results) > $pager->getPageSize())) {
-        $pager->setNextPageID($this->getPagingValue(last($sliced_results)));
+      list($before, $after) = $this->getPageCursors($sliced_results);
+
+      if ($pager->getBeforeID() || ($count > $limit)) {
+        $pager->setNextPageID($after);
       }
 
       if ($pager->getAfterID() ||
-         ($pager->getBeforeID() && (count($results) > $pager->getPageSize()))) {
-        $pager->setPrevPageID($this->getPagingValue(head($sliced_results)));
+         ($pager->getBeforeID() && ($count > $limit))) {
+        $pager->setPrevPageID($before);
       }
     }
 
     return $sliced_results;
   }
 
 
   /**
    * Return the alias this query uses to identify the primary table.
    *
    * Some automatic query constructions may need to be qualified with a table
    * alias if the query performs joins which make column names ambiguous. If
    * this is the case, return the alias for the primary table the query
    * uses; generally the object table which has `id` and `phid` columns.
    *
    * @return string Alias for the primary table.
    */
   protected function getPrimaryTableAlias() {
     return null;
   }
 
   protected function newResultObject() {
     return null;
   }
 
 
 /* -(  Paging  )------------------------------------------------------------- */
 
 
   protected function buildPagingClause(AphrontDatabaseConnection $conn) {
     $orderable = $this->getOrderableColumns();
     $vector = $this->getOrderVector();
 
     if ($this->beforeID !== null) {
       $cursor = $this->beforeID;
       $reversed = true;
     } else if ($this->afterID !== null) {
       $cursor = $this->afterID;
       $reversed = false;
     } else {
       // No paging is being applied to this query so we do not need to
       // construct a paging clause.
       return '';
     }
 
     $keys = array();
     foreach ($vector as $order) {
       $keys[] = $order->getOrderKey();
     }
 
     $value_map = $this->getPagingValueMap($cursor, $keys);
 
     $columns = array();
     foreach ($vector as $order) {
       $key = $order->getOrderKey();
 
       if (!array_key_exists($key, $value_map)) {
         throw new Exception(
           pht(
             'Query "%s" failed to return a value from getPagingValueMap() '.
             'for column "%s".',
             get_class($this),
             $key));
       }
 
       $column = $orderable[$key];
       $column['value'] = $value_map[$key];
 
       $columns[] = $column;
     }
 
     return $this->buildPagingClauseFromMultipleColumns(
       $conn,
       $columns,
       array(
         'reversed' => $reversed,
       ));
   }
 
   protected function getPagingValueMap($cursor, array $keys) {
     // TODO: This is a hack to make this work with existing classes for now.
     return array(
       'id' => $cursor,
     );
   }
 
   protected function loadCursorObject($cursor) {
     $query = newv(get_class($this), array())
       ->setViewer($this->getPagingViewer())
       ->withIDs(array((int)$cursor));
 
     $this->willExecuteCursorQuery($query);
 
     $object = $query->executeOne();
     if (!$object) {
       throw new Exception(
         pht(
           'Cursor "%s" does not identify a valid object.',
           $cursor));
     }
 
     return $object;
   }
 
   protected function willExecuteCursorQuery(
     PhabricatorCursorPagedPolicyAwareQuery $query) {
     return;
   }
 
 
   /**
    * Simplifies the task of constructing a paging clause across multiple
    * columns. In the general case, this looks like:
    *
    *   A > a OR (A = a AND B > b) OR (A = a AND B = b AND C > c)
    *
    * To build a clause, specify the name, type, and value of each column
    * to include:
    *
    *   $this->buildPagingClauseFromMultipleColumns(
    *     $conn_r,
    *     array(
    *       array(
    *         'table' => 't',
    *         'column' => 'title',
    *         'type' => 'string',
    *         'value' => $cursor->getTitle(),
    *         'reverse' => true,
    *       ),
    *       array(
    *         'table' => 't',
    *         'column' => 'id',
    *         'type' => 'int',
    *         'value' => $cursor->getID(),
    *       ),
    *     ),
    *     array(
    *       'reversed' => $is_reversed,
    *     ));
    *
    * This method will then return a composable clause for inclusion in WHERE.
    *
    * @param AphrontDatabaseConnection Connection query will execute on.
    * @param list<map> Column description dictionaries.
    * @param map Additional constuction options.
    * @return string Query clause.
    */
   final protected function buildPagingClauseFromMultipleColumns(
     AphrontDatabaseConnection $conn,
     array $columns,
     array $options) {
 
     foreach ($columns as $column) {
       PhutilTypeSpec::checkMap(
         $column,
         array(
           'table' => 'optional string|null',
           'column' => 'string',
           'value' => 'wild',
           'type' => 'string',
           'reverse' => 'optional bool',
           'unique' => 'optional bool',
           'null' => 'optional string|null',
         ));
     }
 
     PhutilTypeSpec::checkMap(
       $options,
       array(
         'reversed' => 'optional bool',
       ));
 
     $is_query_reversed = idx($options, 'reversed', false);
 
     $clauses = array();
     $accumulated = array();
     $last_key = last_key($columns);
     foreach ($columns as $key => $column) {
       $type = $column['type'];
 
       $null = idx($column, 'null');
       if ($column['value'] === null) {
         if ($null) {
           $value = null;
         } else {
           throw new Exception(
             pht(
               'Column "%s" has null value, but does not specify a null '.
               'behavior.',
               $key));
         }
       } else {
         switch ($type) {
           case 'int':
             $value = qsprintf($conn, '%d', $column['value']);
             break;
           case 'float':
             $value = qsprintf($conn, '%f', $column['value']);
             break;
           case 'string':
             $value = qsprintf($conn, '%s', $column['value']);
             break;
           default:
             throw new Exception(
               pht(
                 'Column "%s" has unknown column type "%s".',
                 $column['column'],
                 $type));
         }
       }
 
       $is_column_reversed = idx($column, 'reverse', false);
       $reverse = ($is_query_reversed xor $is_column_reversed);
 
       $clause = $accumulated;
 
       $table_name = idx($column, 'table');
       $column_name = $column['column'];
       if ($table_name !== null) {
         $field = qsprintf($conn, '%T.%T', $table_name, $column_name);
       } else {
         $field = qsprintf($conn, '%T', $column_name);
       }
 
       $parts = array();
       if ($null) {
         $can_page_if_null = ($null === 'head');
         $can_page_if_nonnull = ($null === 'tail');
 
         if ($reverse) {
           $can_page_if_null = !$can_page_if_null;
           $can_page_if_nonnull = !$can_page_if_nonnull;
         }
 
         $subclause = null;
         if ($can_page_if_null && $value === null) {
           $parts[] = qsprintf(
             $conn,
             '(%Q IS NOT NULL)',
             $field);
         } else if ($can_page_if_nonnull && $value !== null) {
           $parts[] = qsprintf(
             $conn,
             '(%Q IS NULL)',
             $field);
         }
       }
 
       if ($value !== null) {
         $parts[] = qsprintf(
           $conn,
           '%Q %Q %Q',
           $field,
           $reverse ? '>' : '<',
           $value);
       }
 
       if ($parts) {
         if (count($parts) > 1) {
           $clause[] = '('.implode(') OR (', $parts).')';
         } else {
           $clause[] = head($parts);
         }
       }
 
       if ($clause) {
         if (count($clause) > 1) {
           $clauses[] = '('.implode(') AND (', $clause).')';
         } else {
           $clauses[] = head($clause);
         }
       }
 
       if ($value === null) {
         $accumulated[] = qsprintf(
           $conn,
           '%Q IS NULL',
           $field);
       } else {
         $accumulated[] = qsprintf(
           $conn,
           '%Q = %Q',
           $field,
           $value);
       }
     }
 
     return '('.implode(') OR (', $clauses).')';
   }
 
 
 /* -(  Result Ordering  )---------------------------------------------------- */
 
 
   /**
    * Select a result ordering.
    *
    * This is a high-level method which selects an ordering from a predefined
    * list of builtin orders, as provided by @{method:getBuiltinOrders}. These
    * options are user-facing and not exhaustive, but are generally convenient
    * and meaningful.
    *
    * You can also use @{method:setOrderVector} to specify a low-level ordering
    * across individual orderable columns. This offers greater control but is
    * also more involved.
    *
    * @param string Key of a builtin order supported by this query.
    * @return this
    * @task order
    */
   public function setOrder($order) {
     $orders = $this->getBuiltinOrders();
 
     if (empty($orders[$order])) {
       throw new Exception(
         pht(
           'Query "%s" does not support a builtin order "%s". Supported orders '.
           'are: %s.',
           get_class($this),
           $order,
           implode(', ', array_keys($orders))));
     }
 
     $this->builtinOrder = $order;
     $this->orderVector = null;
 
     return $this;
   }
 
 
   /**
    * Select the default builtin result ordering.
    *
    * This sets the result order to the default order among the builtin result
    * orders (see @{method:getBuiltinOrders}). This is often the same as the
    * query's builtin default order vector, but some objects have different
    * default vectors (which are internally-facing) and builtin orders (which
    * are user-facing).
    *
    * For example, repositories sort by ID internally (which is efficient and
    * consistent), but sort by most recent commit as a default builtin (which
    * better aligns with user expectations).
    *
    * @return this
    */
   public function setDefaultBuiltinOrder() {
     return $this->setOrder(head_key($this->getBuiltinOrders()));
   }
 
 
   /**
    * Get builtin orders for this class.
    *
    * In application UIs, we want to be able to present users with a small
    * selection of meaningful order options (like "Order by Title") rather than
    * an exhaustive set of column ordering options.
    *
    * Meaningful user-facing orders are often really orders across multiple
    * columns: for example, a "title" ordering is usually implemented as a
    * "title, id" ordering under the hood.
    *
    * Builtin orders provide a mapping from convenient, understandable
    * user-facing orders to implementations.
    *
    * A builtin order should provide these keys:
    *
    *   - `vector` (`list<string>`): The actual order vector to use.
    *   - `name` (`string`): Human-readable order name.
    *
    * @return map<string, wild> Map from builtin order keys to specification.
    * @task order
    */
   public function getBuiltinOrders() {
     $orders = array(
       'newest' => array(
         'vector' => array('id'),
         'name' => pht('Creation (Newest First)'),
         'aliases' => array('created'),
       ),
       'oldest' => array(
         'vector' => array('-id'),
         'name' => pht('Creation (Oldest First)'),
       ),
     );
 
     $object = $this->newResultObject();
     if ($object instanceof PhabricatorCustomFieldInterface) {
       $list = PhabricatorCustomField::getObjectFields(
         $object,
         PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
       foreach ($list->getFields() as $field) {
         $index = $field->buildOrderIndex();
         if (!$index) {
           continue;
         }
 
         $key = $field->getFieldKey();
         $digest = $field->getFieldIndex();
 
         $full_key = 'custom:'.$key;
         $orders[$full_key] = array(
           'vector' => array($full_key, 'id'),
           'name' => $field->getFieldName(),
         );
       }
     }
 
     return $orders;
   }
 
 
   /**
    * Set a low-level column ordering.
    *
    * This is a low-level method which offers granular control over column
    * ordering. In most cases, applications can more easily use
    * @{method:setOrder} to choose a high-level builtin order.
    *
    * To set an order vector, specify a list of order keys as provided by
    * @{method:getOrderableColumns}.
    *
    * @param PhabricatorQueryOrderVector|list<string> List of order keys.
    * @return this
    * @task order
    */
   public function setOrderVector($vector) {
     $vector = PhabricatorQueryOrderVector::newFromVector($vector);
 
     $orderable = $this->getOrderableColumns();
 
     // Make sure that all the components identify valid columns.
     $unique = array();
     foreach ($vector as $order) {
       $key = $order->getOrderKey();
       if (empty($orderable[$key])) {
         $valid = implode(', ', array_keys($orderable));
         throw new Exception(
           pht(
             'This query ("%s") does not support sorting by order key "%s". '.
             'Supported orders are: %s.',
             get_class($this),
             $key,
             $valid));
       }
 
       $unique[$key] = idx($orderable[$key], 'unique', false);
     }
 
     // Make sure that the last column is unique so that this is a strong
     // ordering which can be used for paging.
     $last = last($unique);
     if ($last !== true) {
       throw new Exception(
         pht(
           'Order vector "%s" is invalid: the last column in an order must '.
           'be a column with unique values, but "%s" is not unique.',
           $vector->getAsString(),
           last_key($unique)));
     }
 
     // Make sure that other columns are not unique; an ordering like "id, name"
     // does not make sense because only "id" can ever have an effect.
     array_pop($unique);
     foreach ($unique as $key => $is_unique) {
       if ($is_unique) {
         throw new Exception(
           pht(
             'Order vector "%s" is invalid: only the last column in an order '.
             'may be unique, but "%s" is a unique column and not the last '.
             'column in the order.',
             $vector->getAsString(),
             $key));
       }
     }
 
     $this->orderVector = $vector;
     return $this;
   }
 
 
   /**
    * Get the effective order vector.
    *
    * @return PhabricatorQueryOrderVector Effective vector.
    * @task order
    */
   protected function getOrderVector() {
     if (!$this->orderVector) {
       if ($this->builtinOrder !== null) {
         $builtin_order = idx($this->getBuiltinOrders(), $this->builtinOrder);
         $vector = $builtin_order['vector'];
       } else {
         $vector = $this->getDefaultOrderVector();
       }
       $vector = PhabricatorQueryOrderVector::newFromVector($vector);
 
       // We call setOrderVector() here to apply checks to the default vector.
       // This catches any errors in the implementation.
       $this->setOrderVector($vector);
     }
 
     return $this->orderVector;
   }
 
 
   /**
    * @task order
    */
   protected function getDefaultOrderVector() {
     return array('id');
   }
 
 
   /**
    * @task order
    */
   public function getOrderableColumns() {
     $columns = array(
       'id' => array(
         'table' => $this->getPrimaryTableAlias(),
         'column' => 'id',
         'reverse' => false,
         'type' => 'int',
         'unique' => true,
       ),
     );
 
     $object = $this->newResultObject();
     if ($object instanceof PhabricatorCustomFieldInterface) {
       $list = PhabricatorCustomField::getObjectFields(
         $object,
         PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
       foreach ($list->getFields() as $field) {
         $index = $field->buildOrderIndex();
         if (!$index) {
           continue;
         }
 
         $key = $field->getFieldKey();
         $digest = $field->getFieldIndex();
 
         $full_key = 'custom:'.$key;
         $columns[$full_key] = array(
           'table' => 'appsearch_order_'.$digest,
           'column' => 'indexValue',
           'type' => $index->getIndexValueType(),
           'null' => 'tail',
         );
       }
     }
 
     return $columns;
   }
 
 
   /**
    * @task order
    */
   final protected function buildOrderClause(AphrontDatabaseConnection $conn) {
     $orderable = $this->getOrderableColumns();
     $vector = $this->getOrderVector();
 
     $parts = array();
     foreach ($vector as $order) {
       $part = $orderable[$order->getOrderKey()];
       if ($order->getIsReversed()) {
         $part['reverse'] = !idx($part, 'reverse', false);
       }
       $parts[] = $part;
     }
 
     return $this->formatOrderClause($conn, $parts);
   }
 
 
   /**
    * @task order
    */
   protected function formatOrderClause(
     AphrontDatabaseConnection $conn,
     array $parts) {
 
     $is_query_reversed = false;
     if ($this->getBeforeID()) {
       $is_query_reversed = !$is_query_reversed;
     }
 
     $sql = array();
     foreach ($parts as $key => $part) {
       $is_column_reversed = !empty($part['reverse']);
 
       $descending = true;
       if ($is_query_reversed) {
         $descending = !$descending;
       }
 
       if ($is_column_reversed) {
         $descending = !$descending;
       }
 
       $table = idx($part, 'table');
       $column = $part['column'];
 
       if ($table !== null) {
         $field = qsprintf($conn, '%T.%T', $table, $column);
       } else {
         $field = qsprintf($conn, '%T', $column);
       }
 
       $null = idx($part, 'null');
       if ($null) {
         switch ($null) {
           case 'head':
             $null_field = qsprintf($conn, '(%Q IS NULL)', $field);
             break;
           case 'tail':
             $null_field = qsprintf($conn, '(%Q IS NOT NULL)', $field);
             break;
           default:
             throw new Exception(
               pht(
                 'NULL value "%s" is invalid. Valid values are "head" and '.
                 '"tail".',
                 $null));
         }
 
         if ($descending) {
           $sql[] = qsprintf($conn, '%Q DESC', $null_field);
         } else {
           $sql[] = qsprintf($conn, '%Q ASC', $null_field);
         }
       }
 
       if ($descending) {
         $sql[] = qsprintf($conn, '%Q DESC', $field);
       } else {
         $sql[] = qsprintf($conn, '%Q ASC', $field);
       }
     }
 
     return qsprintf($conn, 'ORDER BY %Q', implode(', ', $sql));
   }
 
 
 /* -(  Application Search  )------------------------------------------------- */
 
 
   /**
    * Constrain the query with an ApplicationSearch index, requiring field values
    * contain at least one of the values in a set.
    *
    * This constraint can build the most common types of queries, like:
    *
    *   - Find users with shirt sizes "X" or "XL".
    *   - Find shoes with size "13".
    *
    * @param PhabricatorCustomFieldIndexStorage Table where the index is stored.
    * @param string|list<string> One or more values to filter by.
    * @return this
    * @task appsearch
    */
   public function withApplicationSearchContainsConstraint(
     PhabricatorCustomFieldIndexStorage $index,
     $value) {
 
     $this->applicationSearchConstraints[] = array(
       'type'  => $index->getIndexValueType(),
       'cond'  => '=',
       'table' => $index->getTableName(),
       'index' => $index->getIndexKey(),
       'value' => $value,
     );
 
     return $this;
   }
 
 
   /**
    * Constrain the query with an ApplicationSearch index, requiring values
    * exist in a given range.
    *
    * This constraint is useful for expressing date ranges:
    *
    *   - Find events between July 1st and July 7th.
    *
    * The ends of the range are inclusive, so a `$min` of `3` and a `$max` of
    * `5` will match fields with values `3`, `4`, or `5`. Providing `null` for
    * either end of the range will leave that end of the constraint open.
    *
    * @param PhabricatorCustomFieldIndexStorage Table where the index is stored.
    * @param int|null Minimum permissible value, inclusive.
    * @param int|null Maximum permissible value, inclusive.
    * @return this
    * @task appsearch
    */
   public function withApplicationSearchRangeConstraint(
     PhabricatorCustomFieldIndexStorage $index,
     $min,
     $max) {
 
     $index_type = $index->getIndexValueType();
     if ($index_type != 'int') {
       throw new Exception(
         pht(
           'Attempting to apply a range constraint to a field with index type '.
           '"%s", expected type "%s".',
           $index_type,
           'int'));
     }
 
     $this->applicationSearchConstraints[] = array(
       'type' => $index->getIndexValueType(),
       'cond' => 'range',
       'table' => $index->getTableName(),
       'index' => $index->getIndexKey(),
       'value' => array($min, $max),
     );
 
     return $this;
   }
 
 
   /**
    * Order the results by an ApplicationSearch index.
    *
    * @param PhabricatorCustomField Field to which the index belongs.
    * @param PhabricatorCustomFieldIndexStorage Table where the index is stored.
    * @param bool True to sort ascending.
    * @return this
    * @task appsearch
    */
   public function withApplicationSearchOrder(
     PhabricatorCustomField $field,
     PhabricatorCustomFieldIndexStorage $index,
     $ascending) {
 
     $this->applicationSearchOrders[] = array(
       'key' => $field->getFieldKey(),
       'type' => $index->getIndexValueType(),
       'table' => $index->getTableName(),
       'index' => $index->getIndexKey(),
       'ascending' => $ascending,
     );
 
     return $this;
   }
 
 
   /**
    * Get the name of the query's primary object PHID column, for constructing
    * JOIN clauses. Normally (and by default) this is just `"phid"`, but it may
    * be something more exotic.
    *
    * See @{method:getPrimaryTableAlias} if the column needs to be qualified with
    * a table alias.
    *
    * @return string Column name.
    * @task appsearch
    */
   protected function getApplicationSearchObjectPHIDColumn() {
     if ($this->getPrimaryTableAlias()) {
       $prefix = $this->getPrimaryTableAlias().'.';
     } else {
       $prefix = '';
     }
 
     return $prefix.'phid';
   }
 
 
   /**
    * Determine if the JOINs built by ApplicationSearch might cause each primary
    * object to return multiple result rows. Generally, this means the query
    * needs an extra GROUP BY clause.
    *
    * @return bool True if the query may return multiple rows for each object.
    * @task appsearch
    */
   protected function getApplicationSearchMayJoinMultipleRows() {
     foreach ($this->applicationSearchConstraints as $constraint) {
       $type = $constraint['type'];
       $value = $constraint['value'];
       $cond = $constraint['cond'];
 
       switch ($cond) {
         case '=':
           switch ($type) {
             case 'string':
             case 'int':
               if (count((array)$value) > 1) {
                 return true;
               }
               break;
             default:
               throw new Exception(pht('Unknown index type "%s"!', $type));
           }
           break;
         case 'range':
           // NOTE: It's possible to write a custom field where multiple rows
           // match a range constraint, but we don't currently ship any in the
           // upstream and I can't immediately come up with cases where this
           // would make sense.
           break;
         default:
           throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
       }
     }
 
     return false;
   }
 
 
   /**
    * Construct a GROUP BY clause appropriate for ApplicationSearch constraints.
    *
    * @param AphrontDatabaseConnection Connection executing the query.
    * @return string Group clause.
    * @task appsearch
    */
   protected function buildApplicationSearchGroupClause(
     AphrontDatabaseConnection $conn_r) {
 
     if ($this->getApplicationSearchMayJoinMultipleRows()) {
       return qsprintf(
         $conn_r,
         'GROUP BY %Q',
         $this->getApplicationSearchObjectPHIDColumn());
     } else {
       return '';
     }
   }
 
 
   /**
    * Construct a JOIN clause appropriate for applying ApplicationSearch
    * constraints.
    *
    * @param AphrontDatabaseConnection Connection executing the query.
    * @return string Join clause.
    * @task appsearch
    */
   protected function buildApplicationSearchJoinClause(
     AphrontDatabaseConnection $conn_r) {
 
     $joins = array();
     foreach ($this->applicationSearchConstraints as $key => $constraint) {
       $table = $constraint['table'];
       $alias = 'appsearch_'.$key;
       $index = $constraint['index'];
       $cond = $constraint['cond'];
       $phid_column = $this->getApplicationSearchObjectPHIDColumn();
       switch ($cond) {
         case '=':
           $type = $constraint['type'];
           switch ($type) {
             case 'string':
               $constraint_clause = qsprintf(
                 $conn_r,
                 '%T.indexValue IN (%Ls)',
                 $alias,
                 (array)$constraint['value']);
               break;
             case 'int':
               $constraint_clause = qsprintf(
                 $conn_r,
                 '%T.indexValue IN (%Ld)',
                 $alias,
                 (array)$constraint['value']);
               break;
             default:
               throw new Exception(pht('Unknown index type "%s"!', $type));
           }
 
           $joins[] = qsprintf(
             $conn_r,
             'JOIN %T %T ON %T.objectPHID = %Q
               AND %T.indexKey = %s
               AND (%Q)',
             $table,
             $alias,
             $alias,
             $phid_column,
             $alias,
             $index,
             $constraint_clause);
           break;
         case 'range':
           list($min, $max) = $constraint['value'];
           if (($min === null) && ($max === null)) {
             // If there's no actual range constraint, just move on.
             break;
           }
 
           if ($min === null) {
             $constraint_clause = qsprintf(
               $conn_r,
               '%T.indexValue <= %d',
               $alias,
               $max);
           } else if ($max === null) {
             $constraint_clause = qsprintf(
               $conn_r,
               '%T.indexValue >= %d',
               $alias,
               $min);
           } else {
             $constraint_clause = qsprintf(
               $conn_r,
               '%T.indexValue BETWEEN %d AND %d',
               $alias,
               $min,
               $max);
           }
 
           $joins[] = qsprintf(
             $conn_r,
             'JOIN %T %T ON %T.objectPHID = %Q
               AND %T.indexKey = %s
               AND (%Q)',
             $table,
             $alias,
             $alias,
             $phid_column,
             $alias,
             $index,
             $constraint_clause);
           break;
         default:
           throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
       }
     }
 
     foreach ($this->applicationSearchOrders as $key => $order) {
       $table = $order['table'];
       $index = $order['index'];
       $alias = 'appsearch_order_'.$index;
       $phid_column = $this->getApplicationSearchObjectPHIDColumn();
 
       $joins[] = qsprintf(
         $conn_r,
         'LEFT JOIN %T %T ON %T.objectPHID = %Q
           AND %T.indexKey = %s',
         $table,
         $alias,
         $alias,
         $phid_column,
         $alias,
         $index);
     }
 
     return implode(' ', $joins);
   }
 
   protected function getPagingValueMapForCustomFields(
     PhabricatorCustomFieldInterface $object) {
 
     // We have to get the current field values on the cursor object.
     $fields = PhabricatorCustomField::getObjectFields(
       $object,
       PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
     $fields->setViewer($this->getViewer());
     $fields->readFieldsFromStorage($object);
 
     $map = array();
     foreach ($fields->getFields() as $field) {
       $map['custom:'.$field->getFieldKey()] = $field->getValueForStorage();
     }
 
     return $map;
   }
 
   protected function isCustomFieldOrderKey($key) {
     $prefix = 'custom:';
     return !strncmp($key, $prefix, strlen($prefix));
   }
 
 }