diff --git a/src/applications/search/controller/PhabricatorApplicationSearchController.php b/src/applications/search/controller/PhabricatorApplicationSearchController.php index 1b0f9f8f8f..918ccf34f2 100644 --- a/src/applications/search/controller/PhabricatorApplicationSearchController.php +++ b/src/applications/search/controller/PhabricatorApplicationSearchController.php @@ -1,354 +1,363 @@ preface = $preface; return $this; } public function getPreface() { return $this->preface; } public function setQueryKey($query_key) { $this->queryKey = $query_key; return $this; } protected function getQueryKey() { return $this->queryKey; } public function setNavigation(AphrontSideNavFilterView $navigation) { $this->navigation = $navigation; return $this; } protected function getNavigation() { return $this->navigation; } public function setSearchEngine( PhabricatorApplicationSearchEngine $search_engine) { $this->searchEngine = $search_engine; return $this; } protected function getSearchEngine() { return $this->searchEngine; } protected function validateDelegatingController() { $parent = $this->getDelegatingController(); if (!$parent) { throw new Exception( "You must delegate to this controller, not invoke it directly."); } $engine = $this->getSearchEngine(); if (!$engine) { throw new Exception( "Call setEngine() before delegating to this controller!"); } $nav = $this->getNavigation(); if (!$nav) { throw new Exception( "Call setNavigation() before delegating to this controller!"); } $engine->setViewer($this->getRequest()->getUser()); $parent = $this->getDelegatingController(); $interface = 'PhabricatorApplicationSearchResultsControllerInterface'; if (!$parent instanceof $interface) { throw new Exception( "Delegating controller must implement '{$interface}'."); } } public function processRequest() { $this->validateDelegatingController(); $key = $this->getQueryKey(); if ($key == 'edit') { return $this->processEditRequest(); } else { return $this->processSearchRequest(); } } private function processSearchRequest() { $parent = $this->getDelegatingController(); $request = $this->getRequest(); $user = $request->getUser(); $engine = $this->getSearchEngine(); $nav = $this->getNavigation(); if ($request->isFormPost()) { $saved_query = $engine->buildSavedQueryFromRequest($request); $this->saveQuery($saved_query); return id(new AphrontRedirectResponse())->setURI( $engine->getQueryResultsPageURI($saved_query->getQueryKey()).'#R'); } $named_query = null; $run_query = true; $query_key = $this->queryKey; if ($this->queryKey == 'advanced') { $run_query = false; $query_key = $request->getStr('query'); } else if (!strlen($this->queryKey)) { if ($request->isHTTPGet() && $request->getPassthroughRequestData()) { // If this is a GET request and it has some query data, don't // do anything. We'll build and execute a query from it below. // This allows external tools to build URIs like "/query/?users=a,b". } else { // Otherwise, there's no query data so just run the user's default // query for this application. $query_key = head_key($engine->loadEnabledNamedQueries()); } } if ($engine->isBuiltinQuery($query_key)) { $saved_query = $engine->buildSavedQueryFromBuiltin($query_key); $named_query = idx($engine->loadEnabledNamedQueries(), $query_key); } else if ($query_key) { $saved_query = id(new PhabricatorSavedQueryQuery()) ->setViewer($user) ->withQueryKeys(array($query_key)) ->executeOne(); if (!$saved_query) { return new Aphront404Response(); } $named_query = idx($engine->loadEnabledNamedQueries(), $query_key); } else { $saved_query = $engine->buildSavedQueryFromRequest($request); } $nav->selectFilter( 'query/'.$saved_query->getQueryKey(), 'query/advanced'); $form = id(new AphrontFormView()) ->setUser($user); $engine->buildSearchForm($form, $saved_query); $errors = $engine->getErrors(); if ($errors) { $run_query = false; $errors = id(new AphrontErrorView()) ->setTitle(pht('Query Errors')) ->setErrors($errors); } $submit = id(new AphrontFormSubmitControl()) ->setValue(pht('Execute Query')); if ($run_query && !$named_query && $user->isLoggedIn()) { $submit->addCancelButton( '/search/edit/'.$saved_query->getQueryKey().'/', pht('Save Custom Query...')); } $form->appendChild($submit); $filter_view = id(new AphrontListFilterView())->appendChild($form); if ($run_query && $named_query) { if ($named_query->getIsBuiltin()) { $description = pht( 'Showing results for query "%s".', $named_query->getQueryName()); } else { $description = pht( 'Showing results for saved query "%s".', $named_query->getQueryName()); } $filter_view->setCollapsed( pht('Edit Query...'), pht('Hide Query'), $description, $this->getApplicationURI('query/advanced/?query='.$query_key)); } if ($this->getPreface()) { $nav->appendChild($this->getPreface()); } $nav->appendChild($filter_view); if ($run_query) { $nav->appendChild( $anchor = id(new PhabricatorAnchorView()) ->setAnchorName('R')); $query = $engine->buildQueryFromSavedQuery($saved_query); $pager = new AphrontCursorPagerView(); $pager->readFromRequest($request); - $pager->setPageSize($engine->getPageSize($saved_query)); + $page_size = $engine->getPageSize($saved_query); + if (is_finite($page_size)) { + $pager->setPageSize($page_size); + } else { + // Consider an INF pagesize to mean a large finite pagesize. + + // TODO: It would be nice to handle this more gracefully, but math + // with INF seems to vary across PHP versions, systems, and runtimes. + $pager->setPageSize(0xFFFF); + } $objects = $query->setViewer($request->getUser()) ->executeWithCursorPager($pager); $list = $parent->renderResultsList($objects, $saved_query); $nav->appendChild($list); // TODO: This is a bit hacky. if ($list instanceof PHUIObjectItemListView) { $list->setNoDataString(pht("No results found for this query.")); $list->setPager($pager); } else { if ($pager->willShowPagingControls()) { $pager_box = id(new PHUIBoxView()) ->addPadding(PHUI::PADDING_MEDIUM) ->addMargin(PHUI::MARGIN_LARGE) ->setShadow(true) ->appendChild($pager); $nav->appendChild($pager_box); } } } if ($errors) { $nav->appendChild($errors); } if ($named_query) { $title = pht('Query: %s', $named_query->getQueryName()); } else { $title = pht('Advanced Search'); } $crumbs = $parent ->buildApplicationCrumbs() ->addCrumb( id(new PhabricatorCrumbView()) ->setName(pht("Search"))); $nav->setCrumbs($crumbs); return $this->buildApplicationPage( $nav, array( 'title' => $title, 'device' => true, )); } private function processEditRequest() { $parent = $this->getDelegatingController(); $request = $this->getRequest(); $user = $request->getUser(); $engine = $this->getSearchEngine(); $nav = $this->getNavigation(); $named_queries = $engine->loadAllNamedQueries(); $list_id = celerity_generate_unique_node_id(); $list = new PHUIObjectItemListView(); $list->setUser($user); $list->setID($list_id); Javelin::initBehavior( 'search-reorder-queries', array( 'listID' => $list_id, 'orderURI' => '/search/order/'.get_class($engine).'/', )); foreach ($named_queries as $named_query) { $class = get_class($engine); $key = $named_query->getQueryKey(); $item = id(new PHUIObjectItemView()) ->setHeader($named_query->getQueryName()) ->setHref($engine->getQueryResultsPageURI($key)); if ($named_query->getIsBuiltin() && $named_query->getIsDisabled()) { $icon = 'new'; } else { $icon = 'delete'; } $item->addAction( id(new PHUIListItemView()) ->setIcon($icon) ->setHref('/search/delete/'.$key.'/'.$class.'/') ->setWorkflow(true)); if ($named_query->getIsBuiltin()) { if ($named_query->getIsDisabled()) { $item->addIcon('delete-grey', pht('Disabled')); $item->setDisabled(true); } else { $item->addIcon('lock-grey', pht('Builtin')); } } else { $item->addAction( id(new PHUIListItemView()) ->setIcon('edit') ->setHref('/search/edit/'.$key.'/')); } $item->setGrippable(true); $item->addSigil('named-query'); $item->setMetadata( array( 'queryKey' => $named_query->getQueryKey(), )); $list->addItem($item); } $list->setNoDataString(pht('No saved queries.')); $crumbs = $parent ->buildApplicationCrumbs() ->addCrumb( id(new PhabricatorCrumbView()) ->setName(pht("Saved Queries")) ->setHref($engine->getQueryManagementURI())); $nav->selectFilter('query/edit'); $nav->setCrumbs($crumbs); $nav->appendChild($list); return $parent->buildApplicationPage( $nav, array( 'title' => pht("Saved Queries"), 'device' => true, )); } private function saveQuery(PhabricatorSavedQuery $query) { $query->setEngineClassName(get_class($this->getSearchEngine())); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); try { $query->save(); } catch (AphrontQueryDuplicateKeyException $ex) { // Ignore, this is just a repeated search. } unset($unguarded); } protected function buildApplicationMenu() { return $this->getDelegatingController()->buildApplicationMenu(); } } diff --git a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php index 75d325d5fe..3e13fb3d33 100644 --- a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php +++ b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php @@ -1,437 +1,442 @@ 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 ''; } } protected function buildPagingClause( AphrontDatabaseConnection $conn_r) { if ($this->beforeID) { return qsprintf( $conn_r, '%Q %Q %s', $this->getPagingColumn(), $this->getReversePaging() ? '<' : '>', $this->beforeID); } else if ($this->afterID) { return qsprintf( $conn_r, '%Q %Q %s', $this->getPagingColumn(), $this->getReversePaging() ? '>' : '<', $this->afterID); } return null; } final protected function buildOrderClause(AphrontDatabaseConnection $conn_r) { if ($this->beforeID) { return qsprintf( $conn_r, 'ORDER BY %Q %Q', $this->getPagingColumn(), $this->getReversePaging() ? 'DESC' : 'ASC'); } else { return qsprintf( $conn_r, 'ORDER BY %Q %Q', $this->getPagingColumn(), $this->getReversePaging() ? 'ASC' : 'DESC'); } } final protected function didLoadResults(array $results) { if ($this->beforeID) { $results = array_reverse($results, $preserve_keys = true); } return $results; } final public function executeWithCursorPager(AphrontCursorPagerView $pager) { $this->setLimit($pager->getPageSize() + 1); if ($pager->getAfterID()) { $this->setAfterID($pager->getAfterID()); } else if ($pager->getBeforeID()) { $this->setBeforeID($pager->getBeforeID()); } $results = $this->execute(); $sliced_results = $pager->sliceResults($results); if ($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; } /** * Simplifies the task of constructing a paging clause across multiple * columns. In the general case, this looks like: * * A > a OR (A = a AND B > b) OR (A = a AND B = b AND C > c) * * To build a clause, specify the name, type, and value of each column * to include: * * $this->buildPagingClauseFromMultipleColumns( * $conn_r, * array( * array( * 'name' => 'title', * 'type' => 'string', * 'value' => $cursor->getTitle(), * 'reverse' => true, * ), * array( * 'name' => 'id', * 'type' => 'int', * 'value' => $cursor->getID(), * ), * ), * array( * 'reversed' => $is_reversed, * )); * * This method will then return a composable clause for inclusion in WHERE. * * @param AphrontDatabaseConnection Connection query will execute on. * @param list Column description dictionaries. * @param map Additional constuction options. * @return string Query clause. */ final protected function buildPagingClauseFromMultipleColumns( AphrontDatabaseConnection $conn, array $columns, array $options) { foreach ($columns as $column) { PhutilTypeSpec::checkMap( $column, array( 'name' => 'string', 'value' => 'wild', 'type' => 'string', 'reverse' => 'optional bool', )); } PhutilTypeSpec::checkMap( $options, array( 'reversed' => 'optional bool', )); $is_query_reversed = idx($options, 'reversed', false); $clauses = array(); $accumulated = array(); $last_key = last_key($columns); foreach ($columns as $key => $column) { $name = $column['name']; $type = $column['type']; switch ($type) { case 'int': $value = qsprintf($conn, '%d', $column['value']); break; case 'string': $value = qsprintf($conn, '%s', $column['value']); break; default: throw new Exception("Unknown column type '{$type}'!"); } $is_column_reversed = idx($column, 'reverse', false); $reverse = ($is_query_reversed xor $is_column_reversed); $clause = $accumulated; $clause[] = qsprintf( $conn, '%Q %Q %Q', $name, $reverse ? '>' : '<', $value); $clauses[] = '('.implode(') AND (', $clause).')'; $accumulated[] = qsprintf( $conn, '%Q = %Q', $name, $value); } return '('.implode(') OR (', $clauses).')'; } /* -( Application Search )------------------------------------------------- */ /** * Constrain the query with an ApplicationSearch index. This adds a constraint * which requires objects to have one or more corresponding rows in the index * with one of the given values. Combined with appropriate indexes, it 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. * @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; } /** * Get the name of the query's primary object PHID column, for constructing * JOIN clauses. Normally (and by default) this is just `"phid"`, but if the * query construction requires a table alias it may be something like * `"task.phid"`. * * @return string Column name. * @task appsearch */ protected function getApplicationSearchObjectPHIDColumn() { return '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']; switch ($type) { case 'string': case 'int': if (count((array)$value) > 1) { return true; } break; default: throw new Exception("Unknown constraint type '{$type}!"); } } 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(); if ($cond !== '=') { throw new Exception("Unknown constraint condition '{$cond}'!"); } $type = $constraint['type']; switch ($type) { case 'string': $joins[] = qsprintf( $conn_r, 'JOIN %T %T ON %T.objectPHID = %Q AND %T.indexKey = %s AND %T.indexValue IN (%Ls)', $table, $alias, $alias, $phid_column, $alias, $index, $alias, (array)$constraint['value']); break; case 'int': $joins[] = qsprintf( $conn_r, 'JOIN %T %T ON %T.objectPHID = %Q AND %T.indexKey = %s AND %T.indexValue IN (%Ld)', $table, $alias, $alias, $phid_column, $alias, $index, $alias, (array)$constraint['value']); break; default: throw new Exception("Unknown constraint type '{$type}'!"); } } return implode(' ', $joins); } }