diff --git a/src/applications/maniphest/controller/ManiphestTaskListControllerPro.php b/src/applications/maniphest/controller/ManiphestTaskListControllerPro.php index 07337602a6..000895121e 100644 --- a/src/applications/maniphest/controller/ManiphestTaskListControllerPro.php +++ b/src/applications/maniphest/controller/ManiphestTaskListControllerPro.php @@ -1,293 +1,293 @@ queryKey = idx($data, 'queryKey'); } public function processRequest() { $request = $this->getRequest(); $controller = id(new PhabricatorApplicationSearchController($request)) ->setQueryKey($this->queryKey) ->setSearchEngine(new ManiphestTaskSearchEngine()) ->setNavigation($this->buildSideNavView()); return $this->delegateToController($controller); } public function renderResultsList( array $tasks, PhabricatorSavedQuery $query) { assert_instances_of($tasks, 'ManiphestTask'); $viewer = $this->getRequest()->getUser(); // If we didn't match anything, just pick up the default empty state. if (!$tasks) { return id(new PHUIObjectItemListView()) ->setUser($viewer); } - $group_parameter = $query->getParameter('group', 'priority'); - $order_parameter = $query->getParameter('order', 'priority'); + $group_parameter = nonempty($query->getParameter('group'), 'priority'); + $order_parameter = nonempty($query->getParameter('order'), 'priority'); $handles = $this->loadTaskHandles($tasks); $groups = $this->groupTasks( $tasks, $group_parameter, $handles); $can_drag = ($order_parameter == 'priority') && ($group_parameter == 'none' || $group_parameter == 'priority'); $result = array(); $lists = array(); foreach ($groups as $group => $list) { $task_list = new ManiphestTaskListView(); $task_list->setShowBatchControls(true); if ($can_drag) { $task_list->setShowSubpriorityControls(true); } $task_list->setUser($viewer); $task_list->setTasks($list); $task_list->setHandles($handles); $header = javelin_tag( 'h1', array( 'class' => 'maniphest-task-group-header', 'sigil' => 'task-group', 'meta' => array( 'priority' => head($list)->getPriority(), ), ), pht('%s (%s)', $group, new PhutilNumber(count($list)))); $lists[] = phutil_tag( 'div', array( 'class' => 'maniphest-task-group' ), array( $header, $task_list, )); } Javelin::initBehavior( 'maniphest-subpriority-editor', array( 'uri' => '/maniphest/subpriority/', )); return phutil_tag( 'div', array( 'class' => 'maniphest-list-container', ), array( $lists, $this->renderBatchEditor($query), )); } private function loadTaskHandles(array $tasks) { assert_instances_of($tasks, 'ManiphestTask'); $phids = array(); foreach ($tasks as $task) { $assigned_phid = $task->getOwnerPHID(); if ($assigned_phid) { $phids[] = $assigned_phid; } foreach ($task->getProjectPHIDs() as $project_phid) { $phids[] = $project_phid; } } if (!$phids) { return array(); } return id(new PhabricatorHandleQuery()) ->setViewer($this->getRequest()->getUser()) ->withPHIDs($phids) ->execute(); } private function groupTasks(array $tasks, $group, array $handles) { assert_instances_of($tasks, 'ManiphestTask'); assert_instances_of($handles, 'PhabricatorObjectHandle'); $groups = $this->getTaskGrouping($tasks, $group); $results = array(); foreach ($groups as $label_key => $tasks) { $label = $this->getTaskLabelName($group, $label_key, $handles); $results[$label][] = $tasks; } foreach ($results as $label => $task_groups) { $results[$label] = array_mergev($task_groups); } return $results; } private function getTaskGrouping(array $tasks, $group) { switch ($group) { case 'priority': return mgroup($tasks, 'getPriority'); case 'status': return mgroup($tasks, 'getStatus'); case 'assigned': return mgroup($tasks, 'getOwnerPHID'); case 'project': return mgroup($tasks, 'getGroupByProjectPHID'); default: return array(pht('Tasks') => $tasks); } } private function getTaskLabelName($group, $label_key, array $handles) { switch ($group) { case 'priority': return ManiphestTaskPriority::getTaskPriorityName($label_key); case 'status': return ManiphestTaskStatus::getTaskStatusFullName($label_key); case 'assigned': if ($label_key) { return $handles[$label_key]->getFullName(); } else { return pht('(Not Assigned)'); } case 'project': if ($label_key) { return $handles[$label_key]->getFullName(); } else { return pht('(No Project)'); } default: return pht('Tasks'); } } public function buildSideNavView($for_app = false) { $user = $this->getRequest()->getUser(); $nav = new AphrontSideNavFilterView(); $nav->setBaseURI(new PhutilURI($this->getApplicationURI())); if ($for_app) { $nav->addFilter('create', pht('Create Task')); } id(new ManiphestTaskSearchEngine()) ->setViewer($user) ->addNavigationItems($nav->getMenu()); $nav->selectFilter(null); return $nav; } private function renderBatchEditor(PhabricatorSavedQuery $saved_query) { $user = $this->getRequest()->getUser(); Javelin::initBehavior( 'maniphest-batch-selector', array( 'selectAll' => 'batch-select-all', 'selectNone' => 'batch-select-none', 'submit' => 'batch-select-submit', 'status' => 'batch-select-status-cell', 'idContainer' => 'batch-select-id-container', 'formID' => 'batch-select-form', )); $select_all = javelin_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'class' => 'grey button', 'id' => 'batch-select-all', ), pht('Select All')); $select_none = javelin_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'class' => 'grey button', 'id' => 'batch-select-none', ), pht('Clear Selection')); $submit = phutil_tag( 'button', array( 'id' => 'batch-select-submit', 'disabled' => 'disabled', 'class' => 'disabled', ), pht("Batch Edit Selected \xC2\xBB")); $export = javelin_tag( 'a', array( 'href' => '/maniphest/export/'.$saved_query->getQueryKey().'/', 'class' => 'grey button', ), pht('Export to Excel')); $hidden = phutil_tag( 'div', array( 'id' => 'batch-select-id-container', ), ''); $editor = hsprintf( '
'. '
%s
'. ''. ''. ''. ''. ''. ''. ''. '
%s%s%s%s%s%s
'. '
', pht('Batch Task Editor'), $select_all, $select_none, $export, '', $submit, $hidden); $editor = phabricator_form( $user, array( 'method' => 'POST', 'action' => '/maniphest/batch/', 'id' => 'batch-select-form', ), $editor); return $editor; } } diff --git a/src/applications/maniphest/query/ManiphestTaskQuery.php b/src/applications/maniphest/query/ManiphestTaskQuery.php index 637a5cc512..cecf612050 100644 --- a/src/applications/maniphest/query/ManiphestTaskQuery.php +++ b/src/applications/maniphest/query/ManiphestTaskQuery.php @@ -1,743 +1,914 @@ 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; } 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 withPriority($priority) { $this->priority = $priority; return $this; } public function withPriorities(array $priorities) { $this->priorities = $priorities; return $this; } public function withPrioritiesBetween($min, $max) { $this->minPriority = $min; $this->maxPriority = $max; 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 setCalculateRows($calculate_rows) { $this->calculateRows = $calculate_rows; return $this; } public function getRowCount() { if ($this->rowCount === null) { throw new Exception( "You must execute a query with setCalculateRows() before you can ". "retrieve a row count."); } return $this->rowCount; } public function withAnyProjects(array $projects) { $this->anyProjectPHIDs = $projects; return $this; } public function withAnyUserProjects(array $users) { $this->anyUserProjectPHIDs = $users; return $this; } 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 loadPage() { // TODO: (T603) It is possible for a user to find the PHID of a project // they can't see, then query for tasks in that project and deduce the // identity of unknown/invisible projects. Before we allow the user to // execute a project-based PHID query, we should verify that they // can see the project. $task_dao = new ManiphestTask(); $conn = $task_dao->establishConnection('r'); if ($this->calculateRows) { $calc = 'SQL_CALC_FOUND_ROWS'; // Make sure we end up in the right state if we throw a // PhabricatorEmptyQueryException. $this->rowCount = 0; } else { $calc = ''; } $where = array(); $where[] = $this->buildTaskIDsWhereClause($conn); $where[] = $this->buildTaskPHIDsWhereClause($conn); $where[] = $this->buildStatusWhereClause($conn); $where[] = $this->buildStatusesWhereClause($conn); $where[] = $this->buildPriorityWhereClause($conn); $where[] = $this->buildPrioritiesWhereClause($conn); $where[] = $this->buildAuthorWhereClause($conn); $where[] = $this->buildOwnerWhereClause($conn); $where[] = $this->buildSubscriberWhereClause($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, 'dateCreated >= %d', $this->dateCreatedAfter); } if ($this->dateCreatedBefore) { $where[] = qsprintf( $conn, 'dateCreated <= %d', $this->dateCreatedBefore); } + $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.projectPHID) projectCount'; $having = qsprintf( $conn, 'HAVING projectCount = %d', count($this->projectPHIDs)); } $order = $this->buildCustomOrderClause($conn); // TODO: Clean up this nonstandardness. if (!$this->getLimit()) { $this->setLimit(self::DEFAULT_PAGE_SIZE); } $group_column = ''; switch ($this->groupBy) { case self::GROUP_PROJECT: $group_column = qsprintf( $conn, ', projectGroupName.indexedObjectPHID projectGroupPHID'); break; } $rows = queryfx_all( $conn, 'SELECT %Q task.* %Q %Q FROM %T task %Q %Q %Q %Q %Q %Q', $calc, $count, $group_column, $task_dao->getTableName(), $this->buildJoinsClause($conn), $where, $this->buildGroupClause($conn), $having, $order, $this->buildLimitClause($conn)); if ($this->calculateRows) { $count = queryfx_one( $conn, 'SELECT FOUND_ROWS() N'); $this->rowCount = $count['N']; } else { $this->rowCount = null; } 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; } private function buildTaskIDsWhereClause(AphrontDatabaseConnection $conn) { if (!$this->taskIDs) { return null; } return qsprintf( $conn, 'id in (%Ld)', $this->taskIDs); } private function buildTaskPHIDsWhereClause(AphrontDatabaseConnection $conn) { if (!$this->taskPHIDs) { return null; } return qsprintf( $conn, '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 'status = 0'; case self::STATUS_CLOSED: return 'status > 0'; default: $constant = idx($map, $this->status); if (!$constant) { throw new Exception("Unknown status query '{$this->status}'!"); } return qsprintf( $conn, 'status = %d', $constant); } } private function buildStatusesWhereClause(AphrontDatabaseConnection $conn) { if ($this->statuses) { return qsprintf( $conn, 'status IN (%Ld)', $this->statuses); } return null; } private function buildPriorityWhereClause(AphrontDatabaseConnection $conn) { if ($this->priority !== null) { return qsprintf( $conn, 'priority = %d', $this->priority); } elseif ($this->minPriority !== null && $this->maxPriority !== null) { return qsprintf( $conn, 'priority >= %d AND priority <= %d', $this->minPriority, $this->maxPriority); } return null; } private function buildPrioritiesWhereClause(AphrontDatabaseConnection $conn) { if ($this->priorities) { return qsprintf( $conn, 'priority IN (%Ld)', $this->priorities); } return null; } private function buildAuthorWhereClause(AphrontDatabaseConnection $conn) { if (!$this->authorPHIDs) { return null; } return qsprintf( $conn, '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, 'ownerPHID IS NULL'); } else { return qsprintf( $conn, 'ownerPHID IS NOT NULL'); } } if ($this->includeUnowned) { return qsprintf( $conn, 'ownerPHID IN (%Ls) OR ownerPHID IS NULL', $this->ownerPHIDs); } else { return qsprintf( $conn, '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 = new PhabricatorSearchQuery(); $fulltext_query->setQuery($this->fullTextSearch); $fulltext_query->setParameter('limit', PHP_INT_MAX); $fulltext_query->setParameter('type', ManiphestPHIDTypeTask::TYPECONST); $engine = PhabricatorSearchEngineSelector::newSelector()->newEngine(); $fulltext_results = $engine->executeSearch($fulltext_query); if (empty($fulltext_results)) { $fulltext_results = array(null); } return qsprintf( $conn, 'phid IN (%Ls)', $fulltext_results); } private function buildSubscriberWhereClause(AphrontDatabaseConnection $conn) { if (!$this->subscriberPHIDs) { return null; } return qsprintf( $conn, 'subscriber.subscriberPHID IN (%Ls)', $this->subscriberPHIDs); } private function buildProjectWhereClause(AphrontDatabaseConnection $conn) { if (!$this->projectPHIDs && !$this->includeNoProject) { return null; } $parts = array(); if ($this->projectPHIDs) { $parts[] = qsprintf( $conn, 'project.projectPHID in (%Ls)', $this->projectPHIDs); } if ($this->includeNoProject) { $parts[] = qsprintf( $conn, 'project.projectPHID IS NULL'); } return '('.implode(') OR (', $parts).')'; } private function buildAnyProjectWhereClause(AphrontDatabaseConnection $conn) { if (!$this->anyProjectPHIDs) { return null; } return qsprintf( $conn, 'anyproject.projectPHID 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.projectPHID IN (%Ls)', $any_user_project_phids); } private function buildXProjectWhereClause(AphrontDatabaseConnection $conn) { if (!$this->xprojectPHIDs) { return null; } return qsprintf( $conn, 'xproject.projectPHID IS NULL'); } private function buildCustomOrderClause(AphrontDatabaseConnection $conn) { $order = array(); switch ($this->groupBy) { case self::GROUP_NONE: break; case self::GROUP_PRIORITY: $order[] = 'priority'; break; case self::GROUP_OWNER: $order[] = 'ownerOrdering'; break; case self::GROUP_STATUS: $order[] = 'status'; break; case self::GROUP_PROJECT: $order[] = ''; break; default: throw new Exception("Unknown group query '{$this->groupBy}'!"); } switch ($this->orderBy) { case self::ORDER_PRIORITY: $order[] = 'priority'; $order[] = 'subpriority'; $order[] = 'dateModified'; break; case self::ORDER_CREATED: $order[] = 'id'; break; case self::ORDER_MODIFIED: $order[] = 'dateModified'; break; case self::ORDER_TITLE: $order[] = 'title'; break; default: throw new Exception("Unknown order query '{$this->orderBy}'!"); } $order = array_unique($order); if (empty($order)) { return null; } + $reverse = ($this->getBeforeID() xor $this->getReversePaging()); + foreach ($order as $k => $column) { switch ($column) { case 'subpriority': case 'ownerOrdering': case 'title': - $order[$k] = "task.{$column} ASC"; + if ($reverse) { + $order[$k] = "task.{$column} DESC"; + } else { + $order[$k] = "task.{$column} ASC"; + } break; case '': // Put "No Project" at the end of the list. - $order[$k] = - 'projectGroupName.indexedObjectName IS NULL ASC, '. - 'projectGroupName.indexedObjectName ASC'; + if ($reverse) { + $order[$k] = + 'projectGroupName.indexedObjectName IS NULL DESC, '. + 'projectGroupName.indexedObjectName DESC'; + } else { + $order[$k] = + 'projectGroupName.indexedObjectName IS NULL ASC, '. + 'projectGroupName.indexedObjectName ASC'; + } break; default: - $order[$k] = "task.{$column} DESC"; + if ($reverse) { + $order[$k] = "task.{$column} ASC"; + } else { + $order[$k] = "task.{$column} DESC"; + } break; } } return 'ORDER BY '.implode(', ', $order); } private function buildJoinsClause(AphrontDatabaseConnection $conn_r) { $project_dao = new ManiphestTaskProject(); $joins = array(); if ($this->projectPHIDs || $this->includeNoProject) { $joins[] = qsprintf( $conn_r, '%Q JOIN %T project ON project.taskPHID = task.phid', ($this->includeNoProject ? 'LEFT' : ''), $project_dao->getTableName()); } if ($this->anyProjectPHIDs || $this->anyUserProjectPHIDs) { $joins[] = qsprintf( $conn_r, 'JOIN %T anyproject ON anyproject.taskPHID = task.phid', $project_dao->getTableName()); } if ($this->xprojectPHIDs) { $joins[] = qsprintf( $conn_r, 'LEFT JOIN %T xproject ON xproject.taskPHID = task.phid AND xproject.projectPHID IN (%Ls)', $project_dao->getTableName(), $this->xprojectPHIDs); } if ($this->subscriberPHIDs) { $subscriber_dao = new ManiphestTaskSubscriber(); $joins[] = qsprintf( $conn_r, 'JOIN %T subscriber ON subscriber.taskPHID = task.phid', $subscriber_dao->getTableName()); } 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.taskPHID AND projectGroup.projectPHID NOT IN (%Ls)', $project_dao->getTableName(), $ignore_group_phids); } else { $joins[] = qsprintf( $conn_r, 'LEFT JOIN %T projectGroup ON task.phid = projectGroup.taskPHID', $project_dao->getTableName()); } $joins[] = qsprintf( $conn_r, 'LEFT JOIN %T projectGroupName ON projectGroup.projectPHID = projectGroupName.indexedObjectPHID', id(new ManiphestNameIndex())->getTableName()); break; } return implode(' ', $joins); } private function buildGroupClause(AphrontDatabaseConnection $conn_r) { $joined_multiple_project_rows = (count($this->projectPHIDs) > 1) || (count($this->anyProjectPHIDs) > 1); $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_project_rows) { if ($joined_project_name) { return 'GROUP BY task.id, projectGroup.projectPHID'; } else { return 'GROUP BY task.id'; } } 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 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); } + private function loadCursorObject($id) { + $results = id(new ManiphestTaskQuery()) + ->setViewer($this->getViewer()) + ->withIDs(array((int)$id)) + ->execute(); + return head($results); + } + + protected function getPagingValue($result) { + $id = $result->getID(); + + switch ($this->groupBy) { + case self::GROUP_NONE: + return $id; + case self::GROUP_PRIORITY: + return $id.'.'.$result->getPriority(); + case self::GROUP_OWNER: + // TODO: Make this actually work. + return $id.'.AUTHORNAME'; + case self::GROUP_STATUS: + return $id.'.'.$result->getStatus(); + case self::GROUP_PROJECT: + // TODO: Make this actually work. + return $id.'.PROJNAME'; + default: + throw new Exception("Unknown group query '{$this->groupBy}'!"); + } + } + + protected function buildPagingClause(AphrontDatabaseConnection $conn_r) { + $default = parent::buildPagingClause($conn_r); + + $before_id = $this->getBeforeID(); + $after_id = $this->getAfterID(); + + if (!$before_id && !$after_id) { + return $default; + } + + $cursor_id = nonempty($before_id, $after_id); + $cursor_parts = explode('.', $cursor_id, 2); + $task_id = $cursor_parts[0]; + $group_id = idx($cursor_parts, 1); + + $cursor = $this->loadCursorObject($task_id); + if (!$cursor) { + return null; + } + + $columns = array(); + + switch ($this->groupBy) { + case self::GROUP_NONE: + break; + case self::GROUP_PRIORITY: + $columns[] = array( + 'name' => 'task.priority', + 'value' => (int)$group_id, + 'type' => 'int', + ); + break; + case self::GROUP_OWNER: + $columns[] = array( + 'name' => 'task.ownerOrdering', + 'value' => $group_id, + 'type' => 'string', + 'reverse' => true, + ); + break; + case self::GROUP_STATUS: + $columns[] = array( + 'name' => 'task.status', + 'value' => (int)$group_id, + 'type' => 'int', + ); + break; + case self::GROUP_PROJECT: + $columns[] = array( + 'name' => '(projectGroupName.indexedObjectName IS NULL)', + 'value' => (int)(strlen($group_id) ? 0 : 1), + 'type' => 'int', + ); + if (strlen($group_id)) { + $columns[] = array( + 'name' => 'projectGroupName.indexedObjectName', + 'value' => $group_id, + 'type' => 'string', + 'reverse' => true, + ); + } + break; + default: + throw new Exception("Unknown group query '{$this->groupBy}'!"); + } + + switch ($this->orderBy) { + case self::ORDER_PRIORITY: + if ($this->groupBy != self::GROUP_PRIORITY) { + $columns[] = array( + 'name' => 'task.priority', + 'value' => (int)$cursor->getPriority(), + 'type' => 'int', + ); + } + $columns[] = array( + 'name' => 'task.subpriority', + 'value' => (int)$cursor->getSubpriority(), + 'type' => 'int', + 'reverse' => true, + ); + $columns[] = array( + 'name' => 'task.dateModified', + 'value' => (int)$cursor->getDateModified(), + 'type' => 'int', + ); + break; + case self::ORDER_CREATED: + $columns[] = array( + 'name' => 'task.id', + 'value' => (int)$cursor->getID(), + 'type' => 'int', + ); + break; + case self::ORDER_MODIFIED: + $columns[] = array( + 'name' => 'task.dateModified', + 'value' => (int)$cursor->getDateModified(), + 'type' => 'int', + ); + break; + case self::ORDER_TITLE: + $columns[] = array( + 'name' => 'task.title', + 'value' => $cursor->getTitle(), + 'type' => 'string', + ); + $columns[] = array( + 'name' => 'task.id', + 'value' => $cursor->getID(), + 'type' => 'int', + ); + break; + default: + throw new Exception("Unknown order query '{$this->orderBy}'!"); + } + + return $this->buildPagingClauseFromMultipleColumns( + $conn_r, + $columns, + array( + 'reversed' => (bool)($before_id xor $this->getReversePaging()), + )); + } } diff --git a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php index a952d1ae58..93b0d96412 100644 --- a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php +++ b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php @@ -1,229 +1,231 @@ getID(); } protected function getReversePaging() { return false; } protected function nextPage(array $page) { if ($this->beforeID) { $this->beforeID = $this->getPagingValue(last($page)); } else { $this->afterID = $this->getPagingValue(last($page)); } } 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; } final protected function buildLimitClause(AphrontDatabaseConnection $conn_r) { if ($this->getRawResultLimit()) { return qsprintf($conn_r, 'LIMIT %d', $this->getRawResultLimit()); } else { return ''; } } protected function buildPagingClause( AphrontDatabaseConnection $conn_r) { if ($this->beforeID) { return qsprintf( $conn_r, '%Q %Q %s', $this->getPagingColumn(), $this->getReversePaging() ? '<' : '>', $this->beforeID); } else if ($this->afterID) { return qsprintf( $conn_r, '%Q %Q %s', $this->getPagingColumn(), $this->getReversePaging() ? '>' : '<', $this->afterID); } return null; } final protected function buildOrderClause(AphrontDatabaseConnection $conn_r) { if ($this->beforeID) { return qsprintf( $conn_r, 'ORDER BY %Q %Q', $this->getPagingColumn(), $this->getReversePaging() ? 'DESC' : 'ASC'); } else { return qsprintf( $conn_r, 'ORDER BY %Q %Q', $this->getPagingColumn(), $this->getReversePaging() ? 'ASC' : 'DESC'); } } 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); if ($pager->getAfterID()) { $this->setAfterID($pager->getAfterID()); } else if ($pager->getBeforeID()) { $this->setBeforeID($pager->getBeforeID()); } $results = $this->execute(); $sliced_results = $pager->sliceResults($results); - if ($pager->getBeforeID() || (count($results) > $pager->getPageSize())) { - $pager->setNextPageID($this->getPagingValue(last($sliced_results))); - } + if ($sliced_results) { + if ($pager->getBeforeID() || (count($results) > $pager->getPageSize())) { + $pager->setNextPageID($this->getPagingValue(last($sliced_results))); + } - if ($pager->getAfterID() || - ($pager->getBeforeID() && (count($results) > $pager->getPageSize()))) { - $pager->setPrevPageID($this->getPagingValue(head($sliced_results))); + if ($pager->getAfterID() || + ($pager->getBeforeID() && (count($results) > $pager->getPageSize()))) { + $pager->setPrevPageID($this->getPagingValue(head($sliced_results))); + } } return $sliced_results; } /** * 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( * 'name' => 'title', * 'type' => 'string', * 'value' => $cursor->getTitle(), * 'reverse' => true, * ), * array( * 'name' => '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 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( 'name' => 'string', 'value' => 'wild', 'type' => 'string', 'reverse' => 'optional bool', )); } 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) { $name = $column['name']; $type = $column['type']; switch ($type) { case 'int': $value = qsprintf($conn, '%d', $column['value']); break; case 'string': $value = qsprintf($conn, '%s', $column['value']); break; default: throw new Exception("Unknown column type '{$type}'!"); } $is_column_reversed = idx($column, 'reverse', false); $reverse = ($is_query_reversed xor $is_column_reversed); $clause = $accumulated; $clause[] = qsprintf( $conn, '%Q %Q %Q', $name, $reverse ? '>' : '<', $value); $clauses[] = '('.implode(') AND (', $clause).')'; $accumulated[] = qsprintf( $conn, '%Q = %Q', $name, $value); } return '('.implode(') OR (', $clauses).')'; } }