diff --git a/src/applications/people/query/PhabricatorPeopleQuery.php b/src/applications/people/query/PhabricatorPeopleQuery.php --- a/src/applications/people/query/PhabricatorPeopleQuery.php +++ b/src/applications/people/query/PhabricatorPeopleQuery.php @@ -17,6 +17,7 @@ private $isApproved; private $nameLike; private $nameTokens; + private $namePrefixes; private $needPrimaryEmail; private $needProfile; @@ -95,6 +96,11 @@ return $this; } + public function withNamePrefixes(array $prefixes) { + $this->namePrefixes = $prefixes; + return $this; + } + public function needPrimaryEmail($need) { $this->needPrimaryEmail = $need; return $this; @@ -256,6 +262,17 @@ $this->usernames); } + if ($this->namePrefixes) { + $parts = array(); + foreach ($this->namePrefixes as $name_prefix) { + $parts[] = qsprintf( + $conn, + 'user.username LIKE %>', + $name_prefix); + } + $where[] = '('.implode(' OR ', $parts).')'; + } + if ($this->emails !== null) { $where[] = qsprintf( $conn, diff --git a/src/applications/people/typeahead/PhabricatorPeopleDatasource.php b/src/applications/people/typeahead/PhabricatorPeopleDatasource.php --- a/src/applications/people/typeahead/PhabricatorPeopleDatasource.php +++ b/src/applications/people/typeahead/PhabricatorPeopleDatasource.php @@ -17,13 +17,18 @@ public function loadResults() { $viewer = $this->getViewer(); - $tokens = $this->getTokens(); $query = id(new PhabricatorPeopleQuery()) ->setOrderVector(array('username')); - if ($tokens) { - $query->withNameTokens($tokens); + if ($this->getPhase() == self::PHASE_PREFIX) { + $prefix = $this->getPrefixQuery(); + $query->withNamePrefixes(array($prefix)); + } else { + $tokens = $this->getTokens(); + if ($tokens) { + $query->withNameTokens($tokens); + } } $users = $this->executeQuery($query); diff --git a/src/applications/project/query/PhabricatorProjectQuery.php b/src/applications/project/query/PhabricatorProjectQuery.php --- a/src/applications/project/query/PhabricatorProjectQuery.php +++ b/src/applications/project/query/PhabricatorProjectQuery.php @@ -12,6 +12,7 @@ private $slugMap; private $allSlugs; private $names; + private $namePrefixes; private $nameTokens; private $icons; private $colors; @@ -78,6 +79,11 @@ return $this; } + public function withNamePrefixes(array $prefixes) { + $this->namePrefixes = $prefixes; + return $this; + } + public function withNameTokens(array $tokens) { $this->nameTokens = array_values($tokens); return $this; @@ -464,6 +470,17 @@ $this->names); } + if ($this->namePrefixes) { + $parts = array(); + foreach ($this->namePrefixes as $name_prefix) { + $parts[] = qsprintf( + $conn, + 'name LIKE %>', + $name_prefix); + } + $where[] = '('.implode(' OR ', $parts).')'; + } + if ($this->icons !== null) { $where[] = qsprintf( $conn, diff --git a/src/applications/project/typeahead/PhabricatorProjectDatasource.php b/src/applications/project/typeahead/PhabricatorProjectDatasource.php --- a/src/applications/project/typeahead/PhabricatorProjectDatasource.php +++ b/src/applications/project/typeahead/PhabricatorProjectDatasource.php @@ -28,7 +28,10 @@ ->needImages(true) ->needSlugs(true); - if ($tokens) { + if ($this->getPhase() == self::PHASE_PREFIX) { + $prefix = $this->getPrefixQuery(); + $query->withNamePrefixes(array($prefix)); + } else if ($tokens) { $query->withNameTokens($tokens); } diff --git a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php --- a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php +++ b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php @@ -325,6 +325,11 @@ pht('Icon'), pht('Closed'), pht('Sprite'), + pht('Color'), + pht('Type'), + pht('Unique'), + pht('Auto'), + pht('Phase'), )); $result_box = id(new PHUIObjectBoxView()) diff --git a/src/applications/typeahead/datasource/PhabricatorTypeaheadCompositeDatasource.php b/src/applications/typeahead/datasource/PhabricatorTypeaheadCompositeDatasource.php --- a/src/applications/typeahead/datasource/PhabricatorTypeaheadCompositeDatasource.php +++ b/src/applications/typeahead/datasource/PhabricatorTypeaheadCompositeDatasource.php @@ -4,6 +4,8 @@ extends PhabricatorTypeaheadDatasource { private $usable; + private $prefixString; + private $prefixLength; abstract public function getComponentDatasources(); @@ -22,9 +24,51 @@ } public function loadResults() { + $phases = array(); + + // We only need to do a prefix phase query if there's an actual query + // string. If the user didn't type anything, nothing can possibly match it. + if (strlen($this->getRawQuery())) { + $phases[] = self::PHASE_PREFIX; + } + + $phases[] = self::PHASE_CONTENT; + $offset = $this->getOffset(); $limit = $this->getLimit(); + $results = array(); + foreach ($phases as $phase) { + if ($limit) { + $phase_limit = ($offset + $limit) - count($results); + } else { + $phase_limit = 0; + } + + $phase_results = $this->loadResultsForPhase( + $phase, + $phase_limit); + + foreach ($phase_results as $result) { + $results[] = $result; + } + + if ($limit) { + if (count($results) >= $offset + $limit) { + break; + } + } + } + + return $results; + } + + protected function loadResultsForPhase($phase, $limit) { + if ($phase == self::PHASE_PREFIX) { + $this->prefixString = $this->getPrefixQuery(); + $this->prefixLength = strlen($this->prefixString); + } + // If the input query is a function like `members(platy`, and we can // parse the function, we strip the function off and hand the stripped // query to child sources. This makes it easier to implement function @@ -62,28 +106,110 @@ } $source + ->setPhase($phase) ->setFunctionStack($source_stack) ->setRawQuery($source_query) ->setQuery($this->getQuery()) ->setViewer($this->getViewer()); - if ($limit) { - $source->setLimit($offset + $limit); - } - if ($is_browse) { $source->setIsBrowse(true); } - $source_results = $source->loadResults(); - $source_results = $source->didLoadResults($source_results); + if ($limit) { + // If we are loading results from a source with a limit, it may return + // some results which belong to the wrong phase. We need an entire page + // of valid results in the correct phase AFTER any results for the + // wrong phase are filtered for pagination to work correctly. + + // To make sure we can get there, we fetch more and more results until + // enough of them survive filtering to generate a full page. + + // We start by fetching 150% of the results than we think we need, and + // double the amount we overfetch by each time. + $factor = 1.5; + while (true) { + $query_source = clone $source; + $total = (int)ceil($limit * $factor) + 1; + $query_source->setLimit($total); + + $source_results = $query_source->loadResultsForPhase( + $phase, + $limit); + + // If there are fewer unfiltered results than we asked for, we know + // this is the entire result set and we don't need to keep going. + if (count($source_results) < $total) { + $source_results = $query_source->didLoadResults($source_results); + $source_results = $this->filterPhaseResults( + $phase, + $source_results); + break; + } + + // Otherwise, this result set have everything we need, or may not. + // Filter the results that are part of the wrong phase out first... + $source_results = $query_source->didLoadResults($source_results); + $source_results = $this->filterPhaseResults($phase, $source_results); + + // Now check if we have enough results left. If we do, we're all set. + if (count($source_results) >= $total) { + break; + } + + // We filtered out too many results to have a full page left, so we + // need to run the query again, asking for even more results. We'll + // keep doing this until we get a full page or get all of the + // results. + $factor = $factor * 2; + } + } else { + $source_results = $source->loadResults(); + $source_results = $source->didLoadResults($source_results); + $source_results = $this->filterPhaseResults($phase, $source_results); + } + $results[] = $source_results; } $results = array_mergev($results); $results = msort($results, 'getSortKey'); - $count = count($results); + $results = $this->sliceResults($results); + + return $results; + } + + private function filterPhaseResults($phase, $source_results) { + foreach ($source_results as $key => $source_result) { + $result_phase = $this->getResultPhase($source_result); + + if ($result_phase != $phase) { + unset($source_results[$key]); + continue; + } + + $source_result->setPhase($result_phase); + } + + return $source_results; + } + + private function getResultPhase(PhabricatorTypeaheadResult $result) { + if ($this->prefixLength) { + $result_name = phutil_utf8_strtolower($result->getName()); + if (!strncmp($result_name, $this->prefixString, $this->prefixLength)) { + return self::PHASE_PREFIX; + } + } + + return self::PHASE_CONTENT; + } + + protected function sliceResults(array $results) { + $offset = $this->getOffset(); + $limit = $this->getLimit(); + if ($offset || $limit) { if (!$limit) { $limit = count($results); diff --git a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php --- a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php +++ b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php @@ -13,6 +13,10 @@ private $parameters = array(); private $functionStack = array(); private $isBrowse; + private $phase = self::PHASE_CONTENT; + + const PHASE_PREFIX = 'prefix'; + const PHASE_CONTENT = 'content'; public function setLimit($limit) { $this->limit = $limit; @@ -46,6 +50,10 @@ return $this; } + public function getPrefixQuery() { + return phutil_utf8_strtolower($this->getRawQuery()); + } + public function getRawQuery() { return $this->rawQuery; } @@ -81,6 +89,15 @@ return $this->isBrowse; } + public function setPhase($phase) { + $this->phase = $phase; + return $this; + } + + public function getPhase() { + return $this->phase; + } + public function getDatasourceURI() { $uri = new PhutilURI('/typeahead/class/'.get_class($this).'/'); $uri->setQueryParams($this->parameters); @@ -106,6 +123,13 @@ abstract public function getDatasourceApplicationClass(); abstract public function loadResults(); + protected function loadResultsForPhase($phase, $limit) { + // By default, sources just load all of their results in every phase and + // rely on filtering at a higher level to sequence phases correctly. + $this->setLimit($limit); + return $this->loadResults(); + } + protected function didLoadResults(array $results) { return $results; } diff --git a/src/applications/typeahead/storage/PhabricatorTypeaheadResult.php b/src/applications/typeahead/storage/PhabricatorTypeaheadResult.php --- a/src/applications/typeahead/storage/PhabricatorTypeaheadResult.php +++ b/src/applications/typeahead/storage/PhabricatorTypeaheadResult.php @@ -18,6 +18,7 @@ private $unique; private $autocomplete; private $attributes = array(); + private $phase; public function setIcon($icon) { $this->icon = $icon; @@ -154,6 +155,7 @@ $this->tokenType, $this->unique ? 1 : null, $this->autocomplete, + $this->phase, ); while (end($data) === null) { array_pop($data); @@ -211,4 +213,13 @@ return $this; } + public function setPhase($phase) { + $this->phase = $phase; + return $this; + } + + public function getPhase() { + return $this->phase; + } + }