Differential D20291 Diff 48445 src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
Changeset View
Changeset View
Standalone View
Standalone View
src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
<?php | <?php | ||||
/** | /** | ||||
* A query class which uses cursor-based paging. This paging is much more | * A query class which uses cursor-based paging. This paging is much more | ||||
* performant than offset-based paging in the presence of policy filtering. | * performant than offset-based paging in the presence of policy filtering. | ||||
* | * | ||||
* @task cursors Query Cursors | |||||
* @task clauses Building Query Clauses | * @task clauses Building Query Clauses | ||||
* @task appsearch Integration with ApplicationSearch | * @task appsearch Integration with ApplicationSearch | ||||
* @task customfield Integration with CustomField | * @task customfield Integration with CustomField | ||||
* @task paging Paging | * @task paging Paging | ||||
* @task order Result Ordering | * @task order Result Ordering | ||||
* @task edgelogic Working with Edge Logic | * @task edgelogic Working with Edge Logic | ||||
* @task spaces Working with Spaces | * @task spaces Working with Spaces | ||||
*/ | */ | ||||
abstract class PhabricatorCursorPagedPolicyAwareQuery | abstract class PhabricatorCursorPagedPolicyAwareQuery | ||||
extends PhabricatorPolicyAwareQuery { | extends PhabricatorPolicyAwareQuery { | ||||
private $afterID; | private $externalCursorString; | ||||
private $beforeID; | private $internalCursorObject; | ||||
private $isQueryOrderReversed = false; | |||||
private $applicationSearchConstraints = array(); | private $applicationSearchConstraints = array(); | ||||
private $internalPaging; | private $internalPaging; | ||||
private $orderVector; | private $orderVector; | ||||
private $groupVector; | private $groupVector; | ||||
private $builtinOrder; | private $builtinOrder; | ||||
private $edgeLogicConstraints = array(); | private $edgeLogicConstraints = array(); | ||||
private $edgeLogicConstraintsAreValid = false; | private $edgeLogicConstraintsAreValid = false; | ||||
private $spacePHIDs; | private $spacePHIDs; | ||||
private $spaceIsArchived; | private $spaceIsArchived; | ||||
private $ngrams = array(); | private $ngrams = array(); | ||||
private $ferretEngine; | private $ferretEngine; | ||||
private $ferretTokens = array(); | private $ferretTokens = array(); | ||||
private $ferretTables = array(); | private $ferretTables = array(); | ||||
private $ferretQuery; | private $ferretQuery; | ||||
private $ferretMetadata = array(); | private $ferretMetadata = array(); | ||||
protected function getPageCursors(array $page) { | /* -( Cursors )------------------------------------------------------------ */ | ||||
protected function newExternalCursorStringForResult($object) { | |||||
if (!($object instanceof LiskDAO)) { | |||||
throw new Exception( | |||||
pht( | |||||
'Expected to be passed a result object of class "LiskDAO" in '. | |||||
'"newExternalCursorStringForResult()", actually passed "%s". '. | |||||
'Return storage objects from "loadPage()" or override '. | |||||
'"newExternalCursorStringForResult()".', | |||||
phutil_describe_type($object))); | |||||
} | |||||
return (string)$object->getID(); | |||||
} | |||||
protected function newInternalCursorFromExternalCursor($cursor) { | |||||
return $this->newInternalCursorObjectFromID($cursor); | |||||
} | |||||
protected function newPagingMapFromCursorObject( | |||||
PhabricatorQueryCursor $cursor, | |||||
array $keys) { | |||||
$object = $cursor->getObject(); | |||||
return array( | return array( | ||||
$this->getResultCursor(head($page)), | 'id' => (int)$object->getID(), | ||||
$this->getResultCursor(last($page)), | |||||
); | ); | ||||
} | } | ||||
protected function getResultCursor($object) { | final protected function newInternalCursorObjectFromID($id) { | ||||
if (!is_object($object)) { | $viewer = $this->getViewer(); | ||||
$query = newv(get_class($this), array()); | |||||
$query | |||||
->setParentQuery($this) | |||||
->setViewer($viewer) | |||||
->withIDs(array((int)$id)); | |||||
// We're copying our order vector to the subquery so that the subquery | |||||
// knows it should generate any supplemental information required by the | |||||
// ordering. | |||||
// For example, Phriction documents may be ordered by title, but the title | |||||
// isn't a column in the "document" table: the query must JOIN the | |||||
// "content" table to perform the ordering. Passing the ordering to the | |||||
// subquery tells it that we need it to do that JOIN and attach relevant | |||||
// paging information to the internal cursor object. | |||||
// We only expect to load a single result, so the actual result order does | |||||
// not matter. We only want the internal cursor for that result to look | |||||
// like a cursor this parent query would generate. | |||||
$query->setOrderVector($this->getOrderVector()); | |||||
// We're executing the subquery normally to make sure the viewer can | |||||
// actually see the object, and that it's a completely valid object which | |||||
// passes all filtering and policy checks. You aren't allowed to use an | |||||
// object you can't see as a cursor, since this can leak information. | |||||
$result = $query->executeOne(); | |||||
if (!$result) { | |||||
// TODO: Raise a more tailored exception here and make the UI a little | |||||
// prettier? | |||||
throw new Exception( | throw new Exception( | ||||
pht( | pht( | ||||
'Expected object, got "%s".', | 'Cursor "%s" does not identify a valid object in query "%s".', | ||||
gettype($object))); | $id, | ||||
get_class($this))); | |||||
} | } | ||||
return $object->getID(); | // Now that we made sure the viewer can actually see the object the | ||||
// external cursor identifies, return the internal cursor the query | |||||
// generated as a side effect while loading the object. | |||||
return $query->getInternalCursorObject(); | |||||
} | } | ||||
protected function nextPage(array $page) { | final private function getExternalCursorStringForResult($object) { | ||||
// See getPagingViewer() for a description of this flag. | $cursor = $this->newExternalCursorStringForResult($object); | ||||
$this->internalPaging = true; | |||||
if ($this->beforeID !== null) { | if (!is_string($cursor)) { | ||||
$page = array_reverse($page, $preserve_keys = true); | throw new Exception( | ||||
list($before, $after) = $this->getPageCursors($page); | pht( | ||||
$this->beforeID = $before; | 'Expected "newExternalCursorStringForResult()" in class "%s" to '. | ||||
} else { | 'return a string, but got "%s".', | ||||
list($before, $after) = $this->getPageCursors($page); | get_class($this), | ||||
$this->afterID = $after; | phutil_describe_type($cursor))); | ||||
} | |||||
return $cursor; | |||||
} | |||||
final private function getExternalCursorString() { | |||||
return $this->externalCursorString; | |||||
} | } | ||||
final private function setExternalCursorString($external_cursor) { | |||||
$this->externalCursorString = $external_cursor; | |||||
return $this; | |||||
} | } | ||||
final public function setAfterID($object_id) { | final private function getIsQueryOrderReversed() { | ||||
$this->afterID = $object_id; | return $this->isQueryOrderReversed; | ||||
} | |||||
final private function setIsQueryOrderReversed($is_reversed) { | |||||
$this->isQueryOrderReversed = $is_reversed; | |||||
return $this; | return $this; | ||||
} | } | ||||
final protected function getAfterID() { | final private function getInternalCursorObject() { | ||||
return $this->afterID; | return $this->internalCursorObject; | ||||
} | } | ||||
final public function setBeforeID($object_id) { | final private function setInternalCursorObject( | ||||
$this->beforeID = $object_id; | PhabricatorQueryCursor $cursor) { | ||||
$this->internalCursorObject = $cursor; | |||||
return $this; | return $this; | ||||
} | } | ||||
final protected function getBeforeID() { | final private function getInternalCursorFromExternalCursor( | ||||
return $this->beforeID; | $cursor_string) { | ||||
$cursor_object = $this->newInternalCursorFromExternalCursor($cursor_string); | |||||
if (!($cursor_object instanceof PhabricatorQueryCursor)) { | |||||
throw new Exception( | |||||
pht( | |||||
'Expected "newInternalCursorFromExternalCursor()" to return an '. | |||||
'object of class "PhabricatorQueryCursor", but got "%s" (in '. | |||||
'class "%s").', | |||||
phutil_describe_type($cursor_object), | |||||
get_class($this))); | |||||
} | |||||
return $cursor_object; | |||||
} | |||||
final private function getPagingMapFromCursorObject( | |||||
PhabricatorQueryCursor $cursor, | |||||
array $keys) { | |||||
$map = $this->newPagingMapFromCursorObject($cursor, $keys); | |||||
if (!is_array($map)) { | |||||
throw new Exception( | |||||
pht( | |||||
'Expected "newPagingMapFromCursorObject()" to return a map of '. | |||||
'paging values, but got "%s" (in class "%s").', | |||||
phutil_describe_type($map), | |||||
get_class($this))); | |||||
} | |||||
foreach ($keys as $key) { | |||||
if (!array_key_exists($key, $map)) { | |||||
throw new Exception( | |||||
pht( | |||||
'Map returned by "newPagingMapFromCursorObject()" in class "%s" '. | |||||
'omits required key "%s".', | |||||
get_class($this), | |||||
$key)); | |||||
} | |||||
} | |||||
return $map; | |||||
} | |||||
final protected function nextPage(array $page) { | |||||
if (!$page) { | |||||
return; | |||||
} | |||||
$cursor = id(new PhabricatorQueryCursor()) | |||||
->setObject(last($page)); | |||||
$this->setInternalCursorObject($cursor); | |||||
} | } | ||||
final public function getFerretMetadata() { | final public function getFerretMetadata() { | ||||
if (!$this->supportsFerretEngine()) { | if (!$this->supportsFerretEngine()) { | ||||
throw new Exception( | throw new Exception( | ||||
pht( | pht( | ||||
'Unable to retrieve Ferret engine metadata, this class ("%s") does '. | 'Unable to retrieve Ferret engine metadata, this class ("%s") does '. | ||||
'not support the Ferret engine.', | 'not support the Ferret engine.', | ||||
▲ Show 20 Lines • Show All 121 Lines • ▼ Show 20 Lines | final protected function buildLimitClause(AphrontDatabaseConnection $conn) { | ||||
return qsprintf($conn, ''); | return qsprintf($conn, ''); | ||||
} | } | ||||
protected function shouldLimitResults() { | protected function shouldLimitResults() { | ||||
return true; | return true; | ||||
} | } | ||||
final protected function didLoadResults(array $results) { | final protected function didLoadResults(array $results) { | ||||
if ($this->beforeID) { | if ($this->getIsQueryOrderReversed()) { | ||||
$results = array_reverse($results, $preserve_keys = true); | $results = array_reverse($results, $preserve_keys = true); | ||||
} | } | ||||
return $results; | return $results; | ||||
} | } | ||||
final public function executeWithCursorPager(AphrontCursorPagerView $pager) { | final public function executeWithCursorPager(AphrontCursorPagerView $pager) { | ||||
$limit = $pager->getPageSize(); | $limit = $pager->getPageSize(); | ||||
$this->setLimit($limit + 1); | $this->setLimit($limit + 1); | ||||
if ($pager->getAfterID()) { | if (strlen($pager->getAfterID())) { | ||||
$this->setAfterID($pager->getAfterID()); | $this->setExternalCursorString($pager->getAfterID()); | ||||
} else if ($pager->getBeforeID()) { | } else if ($pager->getBeforeID()) { | ||||
$this->setBeforeID($pager->getBeforeID()); | $this->setExternalCursorString($pager->getBeforeID()); | ||||
$this->setIsQueryOrderReversed(true); | |||||
} | } | ||||
$results = $this->execute(); | $results = $this->execute(); | ||||
$count = count($results); | $count = count($results); | ||||
$sliced_results = $pager->sliceResults($results); | $sliced_results = $pager->sliceResults($results); | ||||
if ($sliced_results) { | if ($sliced_results) { | ||||
list($before, $after) = $this->getPageCursors($sliced_results); | |||||
// If we have results, generate external-facing cursors from the visible | |||||
// results. This stops us from leaking any internal details about objects | |||||
// which we loaded but which were not visible to the viewer. | |||||
if ($pager->getBeforeID() || ($count > $limit)) { | if ($pager->getBeforeID() || ($count > $limit)) { | ||||
$pager->setNextPageID($after); | $last_object = last($sliced_results); | ||||
$cursor = $this->getExternalCursorStringForResult($last_object); | |||||
$pager->setNextPageID($cursor); | |||||
} | } | ||||
if ($pager->getAfterID() || | if ($pager->getAfterID() || | ||||
($pager->getBeforeID() && ($count > $limit))) { | ($pager->getBeforeID() && ($count > $limit))) { | ||||
$pager->setPrevPageID($before); | $head_object = head($sliced_results); | ||||
$cursor = $this->getExternalCursorStringForResult($head_object); | |||||
$pager->setPrevPageID($cursor); | |||||
} | } | ||||
} | } | ||||
return $sliced_results; | return $sliced_results; | ||||
} | } | ||||
/** | /** | ||||
▲ Show 20 Lines • Show All 157 Lines • ▼ Show 20 Lines | /* -( Paging )------------------------------------------------------------- */ | ||||
/** | /** | ||||
* @task paging | * @task paging | ||||
*/ | */ | ||||
protected function buildPagingClause(AphrontDatabaseConnection $conn) { | protected function buildPagingClause(AphrontDatabaseConnection $conn) { | ||||
$orderable = $this->getOrderableColumns(); | $orderable = $this->getOrderableColumns(); | ||||
$vector = $this->getOrderVector(); | $vector = $this->getOrderVector(); | ||||
if ($this->beforeID !== null) { | // If we don't have a cursor object yet, it means we're trying to load | ||||
$cursor = $this->beforeID; | // the first result page. We may need to build a cursor object from the | ||||
$reversed = true; | // external string, or we may not need a paging clause yet. | ||||
} else if ($this->afterID !== null) { | $cursor_object = $this->getInternalCursorObject(); | ||||
$cursor = $this->afterID; | if (!$cursor_object) { | ||||
$reversed = false; | $external_cursor = $this->getExternalCursorString(); | ||||
} else { | if ($external_cursor !== null) { | ||||
// No paging is being applied to this query so we do not need to | $cursor_object = $this->getInternalCursorFromExternalCursor( | ||||
// construct a paging clause. | $external_cursor); | ||||
} | |||||
} | |||||
// If we still don't have a cursor object, this is the first result page | |||||
// and we aren't paging it. We don't need to build a paging clause. | |||||
if (!$cursor_object) { | |||||
return qsprintf($conn, ''); | return qsprintf($conn, ''); | ||||
} | } | ||||
$reversed = $this->getIsQueryOrderReversed(); | |||||
$keys = array(); | $keys = array(); | ||||
foreach ($vector as $order) { | foreach ($vector as $order) { | ||||
$keys[] = $order->getOrderKey(); | $keys[] = $order->getOrderKey(); | ||||
} | } | ||||
$value_map = $this->getPagingValueMap($cursor, $keys); | $value_map = $this->getPagingMapFromCursorObject( | ||||
$cursor_object, | |||||
$keys); | |||||
$columns = array(); | $columns = array(); | ||||
foreach ($vector as $order) { | foreach ($vector as $order) { | ||||
$key = $order->getOrderKey(); | $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 = $orderable[$key]; | ||||
$column['value'] = $value_map[$key]; | $column['value'] = $value_map[$key]; | ||||
// If the vector component is reversed, we need to reverse whatever the | // If the vector component is reversed, we need to reverse whatever the | ||||
// order of the column is. | // order of the column is. | ||||
if ($order->getIsReversed()) { | if ($order->getIsReversed()) { | ||||
$column['reverse'] = !idx($column, 'reverse', false); | $column['reverse'] = !idx($column, 'reverse', false); | ||||
} | } | ||||
$columns[] = $column; | $columns[] = $column; | ||||
} | } | ||||
return $this->buildPagingClauseFromMultipleColumns( | return $this->buildPagingClauseFromMultipleColumns( | ||||
$conn, | $conn, | ||||
$columns, | $columns, | ||||
array( | array( | ||||
'reversed' => $reversed, | 'reversed' => $reversed, | ||||
)); | )); | ||||
} | } | ||||
/** | /** | ||||
* @task paging | |||||
*/ | |||||
protected function getPagingValueMap($cursor, array $keys) { | |||||
return array( | |||||
'id' => $cursor, | |||||
); | |||||
} | |||||
/** | |||||
* @task paging | |||||
*/ | |||||
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 in query "%s".', | |||||
$cursor, | |||||
get_class($this))); | |||||
} | |||||
return $object; | |||||
} | |||||
/** | |||||
* @task paging | |||||
*/ | |||||
protected function willExecuteCursorQuery( | |||||
PhabricatorCursorPagedPolicyAwareQuery $query) { | |||||
return; | |||||
} | |||||
/** | |||||
* Simplifies the task of constructing a paging clause across multiple | * Simplifies the task of constructing a paging clause across multiple | ||||
* columns. In the general case, this looks like: | * 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) | * 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 build a clause, specify the name, type, and value of each column | ||||
* to include: | * to include: | ||||
* | * | ||||
▲ Show 20 Lines • Show All 537 Lines • ▼ Show 20 Lines | /* -( Result Ordering )---------------------------------------------------- */ | ||||
/** | /** | ||||
* @task order | * @task order | ||||
*/ | */ | ||||
protected function formatOrderClause( | protected function formatOrderClause( | ||||
AphrontDatabaseConnection $conn, | AphrontDatabaseConnection $conn, | ||||
array $parts, | array $parts, | ||||
$for_union = false) { | $for_union = false) { | ||||
$is_query_reversed = false; | $is_query_reversed = $this->getIsQueryOrderReversed(); | ||||
if ($this->getBeforeID()) { | |||||
$is_query_reversed = !$is_query_reversed; | |||||
} | |||||
$sql = array(); | $sql = array(); | ||||
foreach ($parts as $key => $part) { | foreach ($parts as $key => $part) { | ||||
$is_column_reversed = !empty($part['reverse']); | $is_column_reversed = !empty($part['reverse']); | ||||
$descending = true; | $descending = true; | ||||
if ($is_query_reversed) { | if ($is_query_reversed) { | ||||
$descending = !$descending; | $descending = !$descending; | ||||
▲ Show 20 Lines • Show All 1,878 Lines • Show Last 20 Lines |