diff --git a/src/applications/maniphest/ManiphestTaskQuery.php b/src/applications/maniphest/ManiphestTaskQuery.php index 967dbcb92..dbd4d1215 100644 --- a/src/applications/maniphest/ManiphestTaskQuery.php +++ b/src/applications/maniphest/ManiphestTaskQuery.php @@ -1,705 +1,724 @@ <?php /** * Query tasks by specific criteria. This class uses the higher-performance * but less-general Maniphest indexes to satisfy queries. * * @group maniphest */ 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 $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 $priority = null; + private $priorities; private $minPriority = null; private $maxPriority = null; 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 $limit = null; const DEFAULT_PAGE_SIZE = 1000; private $offset = 0; private $calculateRows = false; private $rowCount = null; private $groupByProjectResults = null; // See comment at bottom for details 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; } 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 getGroupByProjectResults() { return $this->groupByProjectResults; } public function withAnyProjects(array $projects) { $this->anyProjectPHIDs = $projects; return $this; } public function withAnyUserProjects(array $users) { $this->anyUserProjectPHIDs = $users; return $this; } public function loadPage() { $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); $where = $this->formatWhereClause($where); $join = array(); $join[] = $this->buildProjectJoinClause($conn); $join[] = $this->buildAnyProjectJoinClause($conn); $join[] = $this->buildXProjectJoinClause($conn); $join[] = $this->buildSubscriberJoinClause($conn); $join = array_filter($join); if ($join) { $join = implode(' ', $join); } else { $join = ''; } $having = ''; $count = ''; $group = ''; if (count($this->projectPHIDs) > 1 || count($this->anyProjectPHIDs) > 1) { // If we're joining multiple rows, we need to group the results by the // task IDs. $group = 'GROUP BY task.id'; } else { $group = ''; } 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); } if ($this->groupBy == self::GROUP_PROJECT) { $this->setLimit(PHP_INT_MAX); $this->setOffset(0); } $data = queryfx_all( $conn, 'SELECT %Q * %Q FROM %T task %Q %Q %Q %Q %Q %Q', $calc, $count, $task_dao->getTableName(), $join, $where, $group, $having, $order, $this->buildLimitClause($conn)); if ($this->calculateRows) { $count = queryfx_one( $conn, 'SELECT FOUND_ROWS() N'); $this->rowCount = $count['N']; } else { $this->rowCount = null; } $tasks = $task_dao->loadAllFromArray($data); if ($this->groupBy == self::GROUP_PROJECT) { $tasks = $this->applyGroupByProject($tasks); } 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 (!$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); $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 buildProjectJoinClause(AphrontDatabaseConnection $conn) { if (!$this->projectPHIDs && !$this->includeNoProject) { return null; } $project_dao = new ManiphestTaskProject(); return qsprintf( $conn, '%Q JOIN %T project ON project.taskPHID = task.phid', ($this->includeNoProject ? 'LEFT' : ''), $project_dao->getTableName()); } 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->viewer) ->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 buildAnyProjectJoinClause(AphrontDatabaseConnection $conn) { if (!$this->anyProjectPHIDs && !$this->anyUserProjectPHIDs) { return null; } $project_dao = new ManiphestTaskProject(); return qsprintf( $conn, 'JOIN %T anyproject ON anyproject.taskPHID = task.phid', $project_dao->getTableName()); } private function buildXProjectWhereClause(AphrontDatabaseConnection $conn) { if (!$this->xprojectPHIDs) { return null; } return qsprintf( $conn, 'xproject.projectPHID IS NULL'); } private function buildXProjectJoinClause(AphrontDatabaseConnection $conn) { if (!$this->xprojectPHIDs) { return null; } $project_dao = new ManiphestTaskProject(); return qsprintf( $conn, 'LEFT JOIN %T xproject ON xproject.taskPHID = task.phid AND xproject.projectPHID IN (%Ls)', $project_dao->getTableName(), $this->xprojectPHIDs); } private function buildSubscriberJoinClause(AphrontDatabaseConnection $conn) { if (!$this->subscriberPHIDs) { return null; } $subscriber_dao = new ManiphestTaskSubscriber(); return qsprintf( $conn, 'JOIN %T subscriber ON subscriber.taskPHID = task.phid', $subscriber_dao->getTableName()); } 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: // NOTE: We have to load the entire result set and apply this grouping // in the PHP process for now. 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; } foreach ($order as $k => $column) { switch ($column) { case 'subpriority': case 'ownerOrdering': case 'title': $order[$k] = "task.{$column} ASC"; break; default: $order[$k] = "task.{$column} DESC"; break; } } return 'ORDER BY '.implode(', ', $order); } /** * To get paging to work for "group by project", we need to do a bunch of * server-side magic since there's currently no way to sort by project name on * the database. * * As a consequence of this, moreover, because the list we return from here * may include a single task multiple times (once for each project it's in), * sorting gets screwed up in the controller unless we tell it which project * to put the task in each time it appears. Hence the magic field * groupByProjectResults. * * TODO: Move this all to the database. */ private function applyGroupByProject(array $tasks) { assert_instances_of($tasks, 'ManiphestTask'); $project_phids = array(); foreach ($tasks as $task) { foreach ($task->getProjectPHIDs() as $phid) { $project_phids[$phid] = true; } } // TODO: This should use the query's viewer once this class extends // PhabricatorPolicyQuery (T603). $handles = id(new PhabricatorObjectHandleData(array_keys($project_phids))) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->loadHandles(); $max = 1; foreach ($handles as $handle) { $max = max($max, strlen($handle->getName())); } $items = array(); $ii = 0; foreach ($tasks as $key => $task) { $phids = $task->getProjectPHIDs(); if ($this->projectPHIDs) { $phids = array_diff($phids, $this->projectPHIDs); } if ($phids) { foreach ($phids as $phid) { $items[] = array( 'key' => $key, 'proj' => $phid, 'seq' => sprintf( '%'.$max.'s%09d', $handles[$phid]->getName(), $ii), ); } } else { // Sort "no project" tasks first. $items[] = array( 'key' => $key, 'proj' => null, 'seq' => sprintf( '%'.$max.'s%09d', '', $ii), ); } ++$ii; } $items = isort($items, 'seq'); $items = array_slice( $items, nonempty($this->getOffset()), nonempty($this->getLimit(), self::DEFAULT_PAGE_SIZE)); $result = array(); $projects = array(); foreach ($items as $item) { $result[] = $projects[$item['proj']][] = $tasks[$item['key']]; } $this->groupByProjectResults = $projects; return $result; } } diff --git a/src/applications/maniphest/query/ManiphestTaskSearchEngine.php b/src/applications/maniphest/query/ManiphestTaskSearchEngine.php index f3ee7c4b2..3c0461248 100644 --- a/src/applications/maniphest/query/ManiphestTaskSearchEngine.php +++ b/src/applications/maniphest/query/ManiphestTaskSearchEngine.php @@ -1,188 +1,230 @@ <?php final class ManiphestTaskSearchEngine extends PhabricatorApplicationSearchEngine { public function buildSavedQueryFromRequest(AphrontRequest $request) { $saved = new PhabricatorSavedQuery(); $saved->setParameter( 'assignedPHIDs', $this->readUsersFromRequest($request, 'assigned')); $saved->setParameter('withUnassigned', $request->getBool('withUnassigned')); $saved->setParameter( 'authorPHIDs', $this->readUsersFromRequest($request, 'authors')); $saved->setParameter('statuses', $request->getArr('statuses')); + $saved->setParameter('priorities', $request->getArr('priorities')); $saved->setParameter('order', $request->getStr('order')); + $ids = $request->getStrList('ids'); + foreach ($ids as $key => $id) { + $id = trim($id, ' Tt'); + if (!$id || !is_numeric($id)) { + unset($ids[$key]); + } else { + $ids[$key] = $id; + } + } + $saved->setParameter('ids', $ids); + return $saved; } public function buildQueryFromSavedQuery(PhabricatorSavedQuery $saved) { $query = id(new ManiphestTaskQuery()); $author_phids = $saved->getParameter('authorPHIDs'); if ($author_phids) { $query->withAuthors($author_phids); } $with_unassigned = $saved->getParameter('withUnassigned'); if ($with_unassigned) { $query->withOwners(array(null)); } else { $assigned_phids = $saved->getParameter('assignedPHIDs', array()); if ($assigned_phids) { $query->withOwners($assigned_phids); } } $statuses = $saved->getParameter('statuses'); if ($statuses) { $query->withStatuses($statuses); } + $priorities = $saved->getParameter('priorities'); + if ($priorities) { + $query->withPriorities($priorities); + } + $order = $saved->getParameter('order'); $order = idx($this->getOrderValues(), $order); if ($order) { $query->setOrderBy($order); } else { $query->setOrderBy(head($this->getOrderValues())); } + $ids = $saved->getParameter('ids'); + if ($ids) { + $query->withIDs($ids); + } + return $query; } public function buildSearchForm( AphrontFormView $form, PhabricatorSavedQuery $saved) { $assigned_phids = $saved->getParameter('assignedPHIDs', array()); $author_phids = $saved->getParameter('authorPHIDs', array()); $all_phids = array_merge($assigned_phids, $author_phids); if ($all_phids) { $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->requireViewer()) ->withPHIDs($all_phids) ->execute(); } else { $handles = array(); } $assigned_tokens = array_select_keys($handles, $assigned_phids); $assigned_tokens = mpull($assigned_tokens, 'getFullName', 'getPHID'); $author_tokens = array_select_keys($handles, $author_phids); $author_tokens = mpull($author_tokens, 'getFullName', 'getPHID'); $with_unassigned = $saved->getParameter('withUnassigned'); $statuses = $saved->getParameter('statuses', array()); $statuses = array_fuse($statuses); $status_control = id(new AphrontFormCheckboxControl()) ->setLabel(pht('Status')); foreach (ManiphestTaskStatus::getTaskStatusMap() as $status => $name) { $status_control->addCheckbox( 'statuses[]', $status, $name, isset($statuses[$status])); } + $priorities = $saved->getParameter('priorities', array()); + $priorities = array_fuse($priorities); + $priority_control = id(new AphrontFormCheckboxControl()) + ->setLabel(pht('Priority')); + foreach (ManiphestTaskPriority::getTaskPriorityMap() as $pri => $name) { + $priority_control->addCheckbox( + 'priorities[]', + $pri, + $name, + isset($priorities[$pri])); + } + + $ids = $saved->getParameter('ids', array()); + $form ->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/accounts/') ->setName('assigned') ->setLabel(pht('Assigned To')) ->setValue($assigned_tokens)) ->appendChild( id(new AphrontFormCheckboxControl()) ->addCheckbox( 'withUnassigned', 1, pht('Show only unassigned tasks.'), $with_unassigned)) ->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/accounts/') ->setName('authors') ->setLabel(pht('Authors')) ->setValue($author_tokens)) ->appendChild($status_control) + ->appendChild($priority_control) ->appendChild( id(new AphrontFormSelectControl()) ->setName('order') ->setLabel(pht('Order')) ->setValue($saved->getParameter('order')) - ->setOptions($this->getOrderOptions())); + ->setOptions($this->getOrderOptions())) + ->appendChild( + id(new AphrontFormTextControl()) + ->setName('ids') + ->setLabel(pht('Task IDs')) + ->setValue(implode(', ', $ids))); } protected function getURI($path) { return '/maniphest/'.$path; } public function getBuiltinQueryNames() { $names = array(); if ($this->requireViewer()->isLoggedIn()) { $names['assigned'] = pht('Assigned'); $names['authored'] = pht('Authored'); } $names['open'] = pht('Open Tasks'); $names['all'] = pht('All Tasks'); return $names; } public function buildSavedQueryFromBuiltin($query_key) { $query = $this->newSavedQuery(); $query->setQueryKey($query_key); $viewer_phid = $this->requireViewer()->getPHID(); switch ($query_key) { case 'all': return $query; case 'assigned': return $query ->setParameter('assignedPHIDs', array($viewer_phid)) ->setParameter('statuses', array(ManiphestTaskStatus::STATUS_OPEN)); case 'open': return $query ->setParameter('statuses', array(ManiphestTaskStatus::STATUS_OPEN)); case 'authored': return $query ->setParameter('authorPHIDs', array($viewer_phid)) ->setParameter('statuses', array(ManiphestTaskStatus::STATUS_OPEN)); } return parent::buildSavedQueryFromBuiltin($query_key); } private function getOrderOptions() { return array( 'priority' => pht('Priority'), 'updated' => pht('Date Updated'), 'created' => pht('Date Created'), 'title' => pht('Title'), ); } private function getOrderValues() { return array( 'priority' => ManiphestTaskQuery::ORDER_PRIORITY, 'updated' => ManiphestTaskQuery::ORDER_MODIFIED, 'created' => ManiphestTaskQuery::ORDER_CREATED, 'title' => ManiphestTaskQuery::ORDER_TITLE, ); } }