diff --git a/src/applications/conduit/method/maniphest/ConduitAPI_maniphest_query_Method.php b/src/applications/conduit/method/maniphest/ConduitAPI_maniphest_query_Method.php index 73ce3ae55..8d7c535e8 100644 --- a/src/applications/conduit/method/maniphest/ConduitAPI_maniphest_query_Method.php +++ b/src/applications/conduit/method/maniphest/ConduitAPI_maniphest_query_Method.php @@ -1,131 +1,143 @@ <?php /* * Copyright 2012 Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @group conduit * * TODO: Remove maniphest.find, then make this final. * * @concrete-extensible */ class ConduitAPI_maniphest_query_Method extends ConduitAPI_maniphest_Method { public function getMethodDescription() { return "Execute complex searches for Maniphest tasks."; } public function defineParamTypes() { $statuses = array( ManiphestTaskQuery::STATUS_ANY, ManiphestTaskQuery::STATUS_OPEN, ManiphestTaskQuery::STATUS_CLOSED, ManiphestTaskQuery::STATUS_RESOLVED, ManiphestTaskQuery::STATUS_WONTFIX, ManiphestTaskQuery::STATUS_INVALID, ManiphestTaskQuery::STATUS_SPITE, ManiphestTaskQuery::STATUS_DUPLICATE, ); $statuses = implode(', ', $statuses); $orders = array( ManiphestTaskQuery::ORDER_PRIORITY, ManiphestTaskQuery::ORDER_CREATED, ManiphestTaskQuery::ORDER_MODIFIED, ); $orders = implode(', ', $orders); return array( - 'ownerPHIDs' => 'optional list', - 'authorPHIDs' => 'optional list', - 'projectPHIDs' => 'optional list', - 'ccPHIDs' => 'optional list', + 'ids' => 'optional list<uint>', + 'phids' => 'optional list<phid>', + 'ownerPHIDs' => 'optional list<phid>', + 'authorPHIDs' => 'optional list<phid>', + 'projectPHIDs' => 'optional list<phid>', + 'ccPHIDs' => 'optional list<phid>', 'fullText' => 'optional string', 'status' => 'optional enum<'.$statuses.'>', 'order' => 'optional enum<'.$orders.'>', 'limit' => 'optional int', 'offset' => 'optional int', ); } public function defineReturnType() { return 'list'; } public function defineErrorTypes() { return array( ); } protected function execute(ConduitAPIRequest $request) { $query = new ManiphestTaskQuery(); + $task_ids = $request->getValue('ids'); + if ($task_ids) { + $query->withTaskIDs($task_ids); + } + + $task_phids = $request->getValue('phids'); + if ($task_phids) { + $query->withTaskPHIDs($task_phids); + } + $owners = $request->getValue('ownerPHIDs'); if ($owners) { $query->withOwners($owners); } $authors = $request->getValue('authorPHIDs'); if ($authors) { $query->withAuthors($authors); } $projects = $request->getValue('projectPHIDs'); if ($projects) { $query->withAllProjects($projects); } $ccs = $request->getValue('ccPHIDs'); if ($ccs) { $query->withSubscribers($ccs); } $full_text = $request->getValue('fullText'); if ($full_text) { $query->withFullTextSearch($full_text); } $status = $request->getValue('status'); if ($status) { $query->withStatus($status); } $order = $request->getValue('order'); if ($order) { $query->setOrderBy($order); } $limit = $request->getValue('limit'); if ($limit) { $query->setLimit($limit); } $offset = $request->getValue('offset'); if ($offset) { $query->setOffset($offset); } $results = $query->execute(); return $this->buildTaskInfoDictionaries($results); } } diff --git a/src/applications/maniphest/ManiphestTaskQuery.php b/src/applications/maniphest/ManiphestTaskQuery.php index 5f8fcbbcd..4ab9a2359 100644 --- a/src/applications/maniphest/ManiphestTaskQuery.php +++ b/src/applications/maniphest/ManiphestTaskQuery.php @@ -1,659 +1,677 @@ <?php /* * Copyright 2012 Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * 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 PhabricatorQuery { 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 $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 $priority = null; 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 withTaskIDs(array $ids) { $this->taskIDs = $ids; return $this; } + public function withTaskPHIDs(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) { $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 withPriority($priority) { $this->priority = $priority; 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 setLimit($limit) { $this->limit = $limit; return $this; } public function setOffset($offset) { $this->offset = $offset; 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 execute() { $task_dao = new ManiphestTask(); $conn = $task_dao->establishConnection('r'); if ($this->calculateRows) { $calc = 'SQL_CALC_FOUND_ROWS'; } else { $calc = ''; } $where = array(); $where[] = $this->buildTaskIDsWhereClause($conn); + $where[] = $this->buildTaskPHIDsWhereClause($conn); $where[] = $this->buildStatusWhereClause($conn); $where[] = $this->buildPriorityWhereClause($conn); $where[] = $this->buildAuthorWhereClause($conn); $where[] = $this->buildOwnerWhereClause($conn); $where[] = $this->buildSubscriberWhereClause($conn); $where[] = $this->buildProjectWhereClause($conn); $where[] = $this->buildAnyProjectWhereClause($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->buildOrderClause($conn); $offset = (int)nonempty($this->offset, 0); $limit = (int)nonempty($this->limit, self::DEFAULT_PAGE_SIZE); if ($this->groupBy == self::GROUP_PROJECT) { $limit = PHP_INT_MAX; $offset = 0; } $data = queryfx_all( $conn, 'SELECT %Q * %Q FROM %T task %Q %Q %Q %Q %Q LIMIT %d, %d', $calc, $count, $task_dao->getTableName(), $join, $where, $group, $having, $order, $offset, $limit); 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($conn) { + private function buildTaskIDsWhereClause(AphrontDatabaseConnection $conn) { if (!$this->taskIDs) { return null; } return qsprintf( $conn, 'id in (%Ld)', $this->taskIDs); } - private function buildStatusWhereClause($conn) { + 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 buildPriorityWhereClause($conn) { + 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 buildAuthorWhereClause($conn) { + private function buildAuthorWhereClause(AphrontDatabaseConnection $conn) { if (!$this->authorPHIDs) { return null; } return qsprintf( $conn, 'authorPHID in (%Ls)', $this->authorPHIDs); } - private function buildOwnerWhereClause($conn) { + 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($conn) { + 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($conn) { + private function buildSubscriberWhereClause(AphrontDatabaseConnection $conn) { if (!$this->subscriberPHIDs) { return null; } return qsprintf( $conn, 'subscriber.subscriberPHID IN (%Ls)', $this->subscriberPHIDs); } - private function buildProjectWhereClause($conn) { + 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($conn) { + 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($conn) { + private function buildAnyProjectWhereClause(AphrontDatabaseConnection $conn) { if (!$this->anyProjectPHIDs) { return null; } return qsprintf( $conn, 'anyproject.projectPHID IN (%Ls)', $this->anyProjectPHIDs); } - private function buildAnyProjectJoinClause($conn) { + private function buildAnyProjectJoinClause(AphrontDatabaseConnection $conn) { if (!$this->anyProjectPHIDs) { return null; } $project_dao = new ManiphestTaskProject(); return qsprintf( $conn, 'JOIN %T anyproject ON anyproject.taskPHID = task.phid', $project_dao->getTableName()); } - private function buildXProjectWhereClause($conn) { + private function buildXProjectWhereClause(AphrontDatabaseConnection $conn) { if (!$this->xprojectPHIDs) { return null; } return qsprintf( $conn, 'xproject.projectPHID IS NULL'); } - private function buildXProjectJoinClause($conn) { + 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($conn) { + 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 buildOrderClause($conn) { + private function buildOrderClause(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; } } $handles = id(new PhabricatorObjectHandleData(array_keys($project_phids))) ->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->offset), nonempty($this->limit, self::DEFAULT_PAGE_SIZE)); $result = array(); $projects = array(); foreach ($items as $item) { $result[] = $projects[$item['proj']][] = $tasks[$item['key']]; } $this->groupByProjectResults = $projects; return $result; } }