diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index 432bbdb291..54f75adbb8 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -1,737 +1,743 @@ getTransactionType()) { case ManiphestTransaction::TYPE_PRIORITY: if ($this->getIsNewObject()) { return null; } return (int)$object->getPriority(); case ManiphestTransaction::TYPE_STATUS: if ($this->getIsNewObject()) { return null; } return $object->getStatus(); case ManiphestTransaction::TYPE_TITLE: if ($this->getIsNewObject()) { return null; } return $object->getTitle(); case ManiphestTransaction::TYPE_DESCRIPTION: if ($this->getIsNewObject()) { return null; } return $object->getDescription(); case ManiphestTransaction::TYPE_OWNER: return nonempty($object->getOwnerPHID(), null); case ManiphestTransaction::TYPE_PROJECT_COLUMN: // These are pre-populated. return $xaction->getOldValue(); case ManiphestTransaction::TYPE_SUBPRIORITY: return $object->getSubpriority(); case ManiphestTransaction::TYPE_MERGED_INTO: case ManiphestTransaction::TYPE_MERGED_FROM: return null; } } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PRIORITY: return (int)$xaction->getNewValue(); case ManiphestTransaction::TYPE_OWNER: return nonempty($xaction->getNewValue(), null); case ManiphestTransaction::TYPE_STATUS: case ManiphestTransaction::TYPE_TITLE: case ManiphestTransaction::TYPE_DESCRIPTION: case ManiphestTransaction::TYPE_SUBPRIORITY: case ManiphestTransaction::TYPE_PROJECT_COLUMN: case ManiphestTransaction::TYPE_MERGED_INTO: case ManiphestTransaction::TYPE_MERGED_FROM: case ManiphestTransaction::TYPE_UNBLOCK: return $xaction->getNewValue(); } } protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PROJECT_COLUMN: $new_column_phids = $new['columnPHIDs']; $old_column_phids = $old['columnPHIDs']; sort($new_column_phids); sort($old_column_phids); return ($old !== $new); } return parent::transactionHasEffect($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PRIORITY: return $object->setPriority($xaction->getNewValue()); case ManiphestTransaction::TYPE_STATUS: return $object->setStatus($xaction->getNewValue()); case ManiphestTransaction::TYPE_TITLE: return $object->setTitle($xaction->getNewValue()); case ManiphestTransaction::TYPE_DESCRIPTION: return $object->setDescription($xaction->getNewValue()); case ManiphestTransaction::TYPE_OWNER: $phid = $xaction->getNewValue(); // Update the "ownerOrdering" column to contain the full name of the // owner, if the task is assigned. $handle = null; if ($phid) { $handle = id(new PhabricatorHandleQuery()) ->setViewer($this->getActor()) ->withPHIDs(array($phid)) ->executeOne(); } if ($handle) { $object->setOwnerOrdering($handle->getName()); } else { $object->setOwnerOrdering(null); } return $object->setOwnerPHID($phid); case ManiphestTransaction::TYPE_SUBPRIORITY: $object->setSubpriority($xaction->getNewValue()); return; case ManiphestTransaction::TYPE_PROJECT_COLUMN: // these do external (edge) updates return; case ManiphestTransaction::TYPE_MERGED_INTO: $object->setStatus(ManiphestTaskStatus::getDuplicateStatus()); return; case ManiphestTransaction::TYPE_MERGED_FROM: return; } } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PROJECT_COLUMN: $board_phid = idx($xaction->getNewValue(), 'projectPHID'); if (!$board_phid) { throw new Exception( pht("Expected 'projectPHID' in column transaction.")); } $old_phids = idx($xaction->getOldValue(), 'columnPHIDs', array()); $new_phids = idx($xaction->getNewValue(), 'columnPHIDs', array()); if (count($new_phids) !== 1) { throw new Exception( pht("Expected exactly one 'columnPHIDs' in column transaction.")); } $columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($this->requireActor()) ->withPHIDs($new_phids) ->execute(); $columns = mpull($columns, null, 'getPHID'); $positions = id(new PhabricatorProjectColumnPositionQuery()) ->setViewer($this->requireActor()) ->withObjectPHIDs(array($object->getPHID())) ->withBoardPHIDs(array($board_phid)) ->execute(); $before_phid = idx($xaction->getNewValue(), 'beforePHID'); $after_phid = idx($xaction->getNewValue(), 'afterPHID'); if (!$before_phid && !$after_phid && ($old_phids == $new_phids)) { // If we are not moving the object between columns and also not // reordering the position, this is a move on some other order // (like priority). We can leave the positions untouched and just // bail, there's no work to be done. return; } // Otherwise, we're either moving between columns or adjusting the // object's position in the "natural" ordering, so we do need to update // some rows. // Remove all existing column positions on the board. foreach ($positions as $position) { $position->delete(); } // Add the new column positions. foreach ($new_phids as $phid) { $column = idx($columns, $phid); if (!$column) { throw new Exception( pht('No such column "%s" exists!', $phid)); } // Load the other object positions in the column. Note that we must // skip implicit column creation to avoid generating a new position // if the target column is a backlog column. $other_positions = id(new PhabricatorProjectColumnPositionQuery()) ->setViewer($this->requireActor()) ->withColumns(array($column)) ->withBoardPHIDs(array($board_phid)) ->setSkipImplicitCreate(true) ->execute(); $other_positions = msort($other_positions, 'getOrderingKey'); // Set up the new position object. We're going to figure out the // right sequence number and then persist this object with that // sequence number. $new_position = id(new PhabricatorProjectColumnPosition()) ->setBoardPHID($board_phid) ->setColumnPHID($column->getPHID()) ->setObjectPHID($object->getPHID()); $updates = array(); $sequence = 0; // If we're just dropping this into the column without any specific // position information, put it at the top. if (!$before_phid && !$after_phid) { $new_position->setSequence($sequence)->save(); $sequence++; } foreach ($other_positions as $position) { $object_phid = $position->getObjectPHID(); // If this is the object we're moving before and we haven't // saved yet, insert here. if (($before_phid == $object_phid) && !$new_position->getID()) { $new_position->setSequence($sequence)->save(); $sequence++; } // This object goes here in the sequence; we might need to update // the row. if ($sequence != $position->getSequence()) { $updates[$position->getID()] = $sequence; } $sequence++; // If this is the object we're moving after and we haven't saved // yet, insert here. if (($after_phid == $object_phid) && !$new_position->getID()) { $new_position->setSequence($sequence)->save(); $sequence++; } } // We should have found a place to put it. if (!$new_position->getID()) { throw new Exception( pht('Unable to find a place to insert object on column!')); } // If we changed other objects' column positions, bulk reorder them. if ($updates) { $position = new PhabricatorProjectColumnPosition(); $conn_w = $position->establishConnection('w'); $pairs = array(); foreach ($updates as $id => $sequence) { // This is ugly because MySQL gets upset with us if it is // configured strictly and we attempt inserts which can't work. // We'll never actually do these inserts since they'll always // collide (triggering the ON DUPLICATE KEY logic), so we just // provide dummy values in order to get there. $pairs[] = qsprintf( $conn_w, '(%d, %d, "", "", "")', $id, $sequence); } queryfx( $conn_w, 'INSERT INTO %T (id, sequence, boardPHID, columnPHID, objectPHID) VALUES %Q ON DUPLICATE KEY UPDATE sequence = VALUES(sequence)', $position->getTableName(), implode(', ', $pairs)); } } break; default: break; } } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { // When we change the status of a task, update tasks this tasks blocks // with a message to the effect of "alincoln resolved blocking task Txxx." $unblock_xaction = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_STATUS: $unblock_xaction = $xaction; break; } } if ($unblock_xaction !== null) { $blocked_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), ManiphestTaskDependedOnByTaskEdgeType::EDGECONST); if ($blocked_phids) { // In theory we could apply these through policies, but that seems a // little bit surprising. For now, use the actor's vision. $blocked_tasks = id(new ManiphestTaskQuery()) ->setViewer($this->getActor()) ->withPHIDs($blocked_phids) ->needSubscriberPHIDs(true) ->needProjectPHIDs(true) ->execute(); $old = $unblock_xaction->getOldValue(); $new = $unblock_xaction->getNewValue(); foreach ($blocked_tasks as $blocked_task) { $unblock_xactions = array(); $unblock_xactions[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_UNBLOCK) ->setOldValue(array($object->getPHID() => $old)) ->setNewValue(array($object->getPHID() => $new)); id(new ManiphestTransactionEditor()) ->setActor($this->getActor()) ->setActingAsPHID($this->getActingAsPHID()) ->setContentSource($this->getContentSource()) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->applyTransactions($blocked_task, $unblock_xactions); } } } return $xactions; } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { $xactions = mfilter($xactions, 'shouldHide', true); return $xactions; } protected function getMailSubjectPrefix() { return PhabricatorEnv::getEnvConfig('metamta.maniphest.subject-prefix'); } protected function getMailThreadID(PhabricatorLiskDAO $object) { return 'maniphest-task-'.$object->getPHID(); } protected function getMailTo(PhabricatorLiskDAO $object) { $phids = array(); if ($object->getOwnerPHID()) { $phids[] = $object->getOwnerPHID(); } $phids[] = $this->getActingAsPHID(); return $phids; } public function getMailTagsMap() { return array( ManiphestTransaction::MAILTAG_STATUS => pht("A task's status changes."), ManiphestTransaction::MAILTAG_OWNER => pht("A task's owner changes."), ManiphestTransaction::MAILTAG_PRIORITY => pht("A task's priority changes."), ManiphestTransaction::MAILTAG_CC => pht("A task's subscribers change."), ManiphestTransaction::MAILTAG_PROJECTS => pht("A task's associated projects change."), ManiphestTransaction::MAILTAG_UNBLOCK => pht('One of the tasks a task is blocked by changes status.'), ManiphestTransaction::MAILTAG_COLUMN => pht('A task is moved between columns on a workboard.'), ManiphestTransaction::MAILTAG_COMMENT => pht('Someone comments on a task.'), ManiphestTransaction::MAILTAG_OTHER => pht('Other task activity not listed above occurs.'), ); } protected function buildReplyHandler(PhabricatorLiskDAO $object) { return id(new ManiphestReplyHandler()) ->setMailReceiver($object); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $id = $object->getID(); $title = $object->getTitle(); return id(new PhabricatorMetaMTAMail()) ->setSubject("T{$id}: {$title}") ->addHeader('Thread-Topic', "T{$id}: ".$object->getOriginalTitle()); } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = parent::buildMailBody($object, $xactions); if ($this->getIsNewObject()) { $body->addTextSection( pht('TASK DESCRIPTION'), $object->getDescription()); } $body->addLinkSection( pht('TASK DETAIL'), PhabricatorEnv::getProductionURI('/T'.$object->getID())); $board_phids = array(); $type_column = ManiphestTransaction::TYPE_PROJECT_COLUMN; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == $type_column) { $new = $xaction->getNewValue(); $project_phid = idx($new, 'projectPHID'); if ($project_phid) { $board_phids[] = $project_phid; } } } if ($board_phids) { $projects = id(new PhabricatorProjectQuery()) ->setViewer($this->requireActor()) ->withPHIDs($board_phids) ->execute(); foreach ($projects as $project) { $body->addLinkSection( pht('WORKBOARD'), PhabricatorEnv::getProductionURI( '/project/board/'.$project->getID().'/')); } } return $body; } protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return $this->shouldSendMail($object, $xactions); } protected function supportsSearch() { return true; } protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { return id(new HeraldManiphestTaskAdapter()) ->setTask($object); } protected function didApplyHeraldRules( PhabricatorLiskDAO $object, HeraldAdapter $adapter, HeraldTranscript $transcript) { $xactions = array(); $cc_phids = $adapter->getCcPHIDs(); if ($cc_phids) { $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setNewValue(array('+' => $cc_phids)); } $assign_phid = $adapter->getAssignPHID(); if ($assign_phid) { $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_OWNER) ->setNewValue($assign_phid); } $project_phids = $adapter->getProjectPHIDs(); if ($project_phids) { $project_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST; $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $project_type) ->setNewValue( array( '+' => array_fuse($project_phids), )); } return $xactions; } protected function requireCapabilities( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { parent::requireCapabilities($object, $xaction); $app_capability_map = array( ManiphestTransaction::TYPE_PRIORITY => ManiphestEditPriorityCapability::CAPABILITY, ManiphestTransaction::TYPE_STATUS => ManiphestEditStatusCapability::CAPABILITY, ManiphestTransaction::TYPE_OWNER => ManiphestEditAssignCapability::CAPABILITY, PhabricatorTransactions::TYPE_EDIT_POLICY => ManiphestEditPoliciesCapability::CAPABILITY, PhabricatorTransactions::TYPE_VIEW_POLICY => ManiphestEditPoliciesCapability::CAPABILITY, ); $transaction_type = $xaction->getTransactionType(); $app_capability = null; if ($transaction_type == PhabricatorTransactions::TYPE_EDGE) { switch ($xaction->getMetadataValue('edge:type')) { case PhabricatorProjectObjectHasProjectEdgeType::EDGECONST: $app_capability = ManiphestEditProjectsCapability::CAPABILITY; break; } } else { $app_capability = idx($app_capability_map, $transaction_type); } if ($app_capability) { $app = id(new PhabricatorApplicationQuery()) ->setViewer($this->getActor()) ->withClasses(array('PhabricatorManiphestApplication')) ->executeOne(); PhabricatorPolicyFilter::requireCapability( $this->getActor(), $app, $app_capability); } } protected function adjustObjectForPolicyChecks( PhabricatorLiskDAO $object, array $xactions) { $copy = parent::adjustObjectForPolicyChecks($object, $xactions); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_OWNER: $copy->setOwnerPHID($xaction->getNewValue()); break; default: continue; } } return $copy; } /** * Get priorities for moving a task to a new priority. */ public static function getEdgeSubpriority( $priority, $is_end) { $query = id(new ManiphestTaskQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) - ->setOrderBy(ManiphestTaskQuery::ORDER_PRIORITY) ->withPriorities(array($priority)) ->setLimit(1); if ($is_end) { - $query->setReversePaging(true); + $query->setOrderVector(array('-priority', '-subpriority', '-id')); + } else { + $query->setOrderVector(array('priority', 'subpriority', 'id')); } $result = $query->executeOne(); $step = (double)(2 << 32); if ($result) { $base = $result->getSubpriority(); if ($is_end) { $sub = ($base - $step); } else { $sub = ($base + $step); } } else { $sub = 0; } return array($priority, $sub); } /** * Get priorities for moving a task before or after another task. */ public static function getAdjacentSubpriority( ManiphestTask $dst, $is_after) { $query = id(new ManiphestTaskQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->setOrderBy(ManiphestTaskQuery::ORDER_PRIORITY) ->withPriorities(array($dst->getPriority())) ->setLimit(1); if ($is_after) { $query->setAfterID($dst->getID()); } else { $query->setBeforeID($dst->getID()); } $adjacent = $query->executeOne(); $base = $dst->getSubpriority(); $step = (double)(2 << 32); // If we find an adjacent task, we average the two subpriorities and // return the result. if ($adjacent) { // If the adjacent task has a subpriority that is identical or very // close to the task we're looking at, we're going to move it and all // tasks with the same subpriority a little farther down the subpriority // scale. if (abs($adjacent->getSubpriority() - $base) < 0.01) { $conn_w = $adjacent->establishConnection('w'); // Get all of the tasks with the same subpriority as the adjacent // task, including the adjacent task itself. $shift_base = $adjacent->getSubpriority(); - $shift_all = id(new ManiphestTaskQuery()) + $query = id(new ManiphestTaskQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) - ->setOrderBy(ManiphestTaskQuery::ORDER_PRIORITY) ->withPriorities(array($adjacent->getPriority())) - ->withSubpriorities(array($shift_base)) - ->setReversePaging(!$is_after) - ->execute(); + ->withSubpriorities(array($shift_base)); + + if (!$is_after) { + $query->setOrderVector(array('-priority', '-subpriority', '-id')); + } else { + $query->setOrderVector(array('priority', 'subpriority', 'id')); + } + + $shift_all = $query->execute(); $shift_last = last($shift_all); // Find the subpriority before or after the task at the end of the // block. list($shift_pri, $shift_sub) = self::getAdjacentSubpriority( $shift_last, $is_after); $delta = ($shift_sub - $shift_base); $count = count($shift_all); $shift = array(); $cursor = 1; foreach ($shift_all as $shift_task) { $shift_target = $shift_base + (($cursor / $count) * $delta); $cursor++; queryfx( $conn_w, 'UPDATE %T SET subpriority = %f WHERE id = %d', $adjacent->getTableName(), $shift_target, $shift_task->getID()); // If we're shifting the adjacent task, update it. if ($shift_task->getID() == $adjacent->getID()) { $adjacent->setSubpriority($shift_target); } // If we're shifting the original target task, update the base // subpriority. if ($shift_task->getID() == $dst->getID()) { $base = $shift_target; } } } $sub = ($adjacent->getSubpriority() + $base) / 2; } else { // Otherwise, we take a step away from the target's subpriority and // use that. if ($is_after) { $sub = ($base - $step); } else { $sub = ($base + $step); } } return array($dst->getPriority(), $sub); } } diff --git a/src/applications/maniphest/query/ManiphestTaskQuery.php b/src/applications/maniphest/query/ManiphestTaskQuery.php index df752ccfeb..433a4cec7b 100644 --- a/src/applications/maniphest/query/ManiphestTaskQuery.php +++ b/src/applications/maniphest/query/ManiphestTaskQuery.php @@ -1,1140 +1,1014 @@ 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 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->buildPrioritiesWhereClause($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)); } - $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 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, - $order, + $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 buildPrioritiesWhereClause(AphrontDatabaseConnection $conn) { - if ($this->priorities) { - return qsprintf( - $conn, - 'task.priority IN (%Ld)', - $this->priorities); - } - - if ($this->subpriorities) { - return qsprintf( - $conn, - 'task.subpriority IN (%Lf)', - $this->subpriorities); - } - - 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 buildCustomOrderClause(AphrontDatabaseConnection $conn) { - $reverse = ($this->getBeforeID() xor $this->getReversePaging()); - - $order = array(); - - switch ($this->groupBy) { - case self::GROUP_NONE: - break; - case self::GROUP_PRIORITY: - $order[] = 'task.priority'; - break; - case self::GROUP_OWNER: - $order[] = 'task.ownerOrdering'; - break; - case self::GROUP_STATUS: - $order[] = 'task.status'; - break; - case self::GROUP_PROJECT: - $order[] = ''; - break; - default: - throw new Exception("Unknown group query '{$this->groupBy}'!"); - } - - $app_order = $this->buildApplicationSearchOrders($conn, $reverse); - - if (!$app_order) { - switch ($this->orderBy) { - case self::ORDER_PRIORITY: - $order[] = 'task.priority'; - $order[] = 'task.subpriority'; - $order[] = 'task.dateModified'; - break; - case self::ORDER_CREATED: - $order[] = 'task.id'; - break; - case self::ORDER_MODIFIED: - $order[] = 'task.dateModified'; - break; - case self::ORDER_TITLE: - $order[] = 'task.title'; - break; - default: - throw new Exception("Unknown order query '{$this->orderBy}'!"); - } - } - - $order = array_unique($order); - - if (empty($order) && empty($app_order)) { - return null; - } - - foreach ($order as $k => $column) { - switch ($column) { - case 'subpriority': - case 'ownerOrdering': - case 'title': - if ($reverse) { - $order[$k] = "{$column} DESC"; - } else { - $order[$k] = "{$column} ASC"; - } - break; - case '': - // Put "No Project" at the end of the list. - 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: - if ($reverse) { - $order[$k] = "{$column} ASC"; - } else { - $order[$k] = "{$column} DESC"; - } - break; - } - } - - if ($app_order) { - foreach ($app_order as $order_by) { - $order[] = $order_by; - } - } - - if ($reverse) { - $order[] = 'task.id ASC'; - } else { - $order[] = 'task.id DESC'; - } - - return 'ORDER BY '.implode(', ', $order); - } - 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 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) { $id = $result->getID(); switch ($this->groupBy) { case self::GROUP_NONE: - return $id; + case self::GROUP_STATUS: case self::GROUP_PRIORITY: - return $id.'.'.$result->getPriority(); case self::GROUP_OWNER: - return rtrim($id.'.'.$result->getOwnerPHID(), '.'); - case self::GROUP_STATUS: - return $id.'.'.$result->getStatus(); + return $id; case self::GROUP_PROJECT: return rtrim($id.'.'.$result->getGroupByProjectPHID(), '.'); default: throw new Exception("Unknown group query '{$this->groupBy}'!"); } } - protected function buildPagingClause(AphrontDatabaseConnection $conn_r) { - $before_id = $this->getBeforeID(); - $after_id = $this->getAfterID(); - - if (!$before_id && !$after_id) { - return ''; - } + 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', + ), + ); + } - $cursor_id = nonempty($before_id, $after_id); - $cursor_parts = explode('.', $cursor_id, 2); + protected function getPagingValueMap($cursor, array $keys) { + $cursor_parts = explode('.', $cursor, 2); $task_id = $cursor_parts[0]; $group_id = idx($cursor_parts, 1); - $cursor = $this->loadCursorObject($task_id); - if (!$cursor) { - // We may loop if we have a cursor and don't build a paging clause; fail - // instead. - throw new PhabricatorEmptyQueryException(); - } - - $columns = array(); - - switch ($this->groupBy) { - case self::GROUP_NONE: - break; - case self::GROUP_PRIORITY: - $columns[] = array( - 'table' => 'task', - 'column' => 'priority', - 'value' => (int)$group_id, - 'type' => 'int', - ); - break; - case self::GROUP_OWNER: - $value = null; - if ($group_id) { - $paging_users = id(new PhabricatorPeopleQuery()) - ->setViewer($this->getViewer()) - ->withPHIDs(array($group_id)) - ->execute(); - if ($paging_users) { - $value = head($paging_users)->getUsername(); - } - } - $columns[] = array( - 'table' => 'task', - 'column' => 'ownerOrdering', - 'value' => $value, - 'type' => 'string', - 'null' => 'head', - 'reverse' => true, - ); - break; - case self::GROUP_STATUS: - $columns[] = array( - 'table' => 'task', - 'column' => 'status', - 'value' => $group_id, - 'type' => 'string', - ); - break; - case self::GROUP_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(); - } - } + $task = $this->loadCursorObject($task_id); - $columns[] = array( - 'table' => 'projectGroupName', - 'column' => 'indexedObjectName', - 'value' => $value, - 'type' => 'string', - 'null' => 'head', - 'reverse' => true, - ); - break; - default: - throw new Exception("Unknown group query '{$this->groupBy}'!"); - } + $map = array( + 'id' => $task->getID(), + 'priority' => $task->getPriority(), + 'subpriority' => $task->getSubpriority(), + 'owner' => $task->getOwnerOrdering(), + 'status' => $task->getStatus(), + 'title' => $task->getTitle(), + 'updated' => $task->getDateModified(), + ); - $app_columns = $this->buildApplicationSearchPagination($conn_r, $cursor); - if ($app_columns) { - $columns = array_merge($columns, $app_columns); - } else { - switch ($this->orderBy) { - case self::ORDER_PRIORITY: - if ($this->groupBy != self::GROUP_PRIORITY) { - $columns[] = array( - 'table' => 'task', - 'column' => 'priority', - 'value' => (int)$cursor->getPriority(), - 'type' => 'int', - ); + 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(); + } } - $columns[] = array( - 'table' => 'task', - 'column' => 'subpriority', - 'value' => $cursor->getSubpriority(), - 'type' => 'float', - ); - $columns[] = array( - 'table' => 'task', - 'column' => 'dateModified', - 'value' => (int)$cursor->getDateModified(), - 'type' => 'int', - ); + $map[$key] = $value; break; - case self::ORDER_CREATED: - // This just uses the ID column, below. - break; - case self::ORDER_MODIFIED: - $columns[] = array( - 'table' => 'task', - 'column' => 'dateModified', - 'value' => (int)$cursor->getDateModified(), - 'type' => 'int', - ); - break; - case self::ORDER_TITLE: - $columns[] = array( - 'table' => 'task', - 'column' => 'title', - 'value' => $cursor->getTitle(), - 'type' => 'string', - ); - break; - default: - throw new Exception("Unknown order query '{$this->orderBy}'!"); } } - $columns[] = array( - 'table' => 'task', - 'column' => 'id', - 'value' => $cursor->getID(), - 'type' => 'int', - ); + foreach ($keys as $key) { + if ($this->isCustomFieldOrderKey($key)) { + $map += $this->getPagingValueMapForCustomFields($task); + break; + } + } - return $this->buildPagingClauseFromMultipleColumns( - $conn_r, - $columns, - array( - 'reversed' => (bool)($before_id xor $this->getReversePaging()), - )); + return $map; } protected function getPrimaryTableAlias() { return 'task'; } public function getQueryApplicationClass() { return 'PhabricatorManiphestApplication'; } - public function setReversePaging($reverse_paging) { - $this->reversePaging = $reverse_paging; - return $this; - } - - protected function getReversePaging() { - return $this->reversePaging; - } - } diff --git a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php index 848857b8cf..fdc8024ff2 100644 --- a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php +++ b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php @@ -1,1030 +1,1036 @@ getID(); } protected function getReversePaging() { return false; } 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)); } 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; } /** * 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); if ($pager->getAfterID()) { $this->setAfterID($pager->getAfterID()); } else if ($pager->getBeforeID()) { $this->setBeforeID($pager->getBeforeID()); } $results = $this->execute(); $sliced_results = $pager->sliceResults($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))); } } 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(); // TODO: Remove this once subqueries modernize. if (!$orderable) { if ($this->beforeID) { return qsprintf( $conn, '%Q %Q %s', $this->getPagingColumn(), $this->getReversePaging() ? '<' : '>', $this->beforeID); } else if ($this->afterID) { return qsprintf( $conn, '%Q %Q %s', $this->getPagingColumn(), $this->getReversePaging() ? '>' : '<', $this->afterID); } else { return null; } } $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 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 )---------------------------------------------------- */ /** * @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; } /** * @task order */ protected function getOrderVector() { if (!$this->orderVector) { $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() { // TODO: Remove this once all subclasses move off the old stuff. if ($this->getPagingColumn() !== 'id') { // This class has bad old custom logic around paging, so return nothing // here. This deactivates the new order code. return array(); } - return array( + $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(); // TODO: Remove this once all subclasses move off the old stuff. We'll // only enter this block for code using older ordering mechanisms. New // code should expose an orderable column list. if (!$orderable) { if ($this->beforeID) { return qsprintf( $conn, 'ORDER BY %Q %Q', $this->getPagingColumn(), $this->getReversePaging() ? 'DESC' : 'ASC'); } else { return qsprintf( $conn, 'ORDER BY %Q %Q', $this->getPagingColumn(), $this->getReversePaging() ? 'ASC' : 'DESC'); } } $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->getReversePaging()) { $is_query_reversed = !$is_query_reversed; } 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 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']; - $alias = 'appsearch_order_'.$key; $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 buildApplicationSearchOrders( - AphrontDatabaseConnection $conn_r, - $reverse) { - - $orders = array(); - foreach ($this->applicationSearchOrders as $key => $order) { - $alias = 'appsearch_order_'.$key; - - if ($order['ascending'] xor $reverse) { - $orders[] = qsprintf($conn_r, '%T.indexValue ASC', $alias); - } else { - $orders[] = qsprintf($conn_r, '%T.indexValue DESC', $alias); - } - } - - return $orders; - } - - protected function buildApplicationSearchPagination( - AphrontDatabaseConnection $conn_r, - $cursor) { + protected function getPagingValueMapForCustomFields( + PhabricatorCustomFieldInterface $object) { // We have to get the current field values on the cursor object. $fields = PhabricatorCustomField::getObjectFields( - $cursor, + $object, PhabricatorCustomField::ROLE_APPLICATIONSEARCH); $fields->setViewer($this->getViewer()); - $fields->readFieldsFromStorage($cursor); - - $fields = mpull($fields->getFields(), null, 'getFieldKey'); + $fields->readFieldsFromStorage($object); - $columns = array(); - foreach ($this->applicationSearchOrders as $key => $order) { - $alias = 'appsearch_order_'.$key; - - $field = idx($fields, $order['key']); - - $columns[] = array( - 'name' => $alias.'.indexValue', - 'value' => $field->getValueForStorage(), - 'type' => $order['type'], - ); + $map = array(); + foreach ($fields->getFields() as $field) { + $map['custom:'.$field->getFieldKey()] = $field->getValueForStorage(); } - return $columns; + return $map; + } + + protected function isCustomFieldOrderKey($key) { + $prefix = 'custom:'; + return !strncmp($key, $prefix, strlen($prefix)); } }