4 * A query class which uses cursor-based paging. This paging is much more
5 * performant than offset-based paging in the presence of policy filtering.
7 * @task cursors Query Cursors
8 * @task clauses Building Query Clauses
9 * @task appsearch Integration with ApplicationSearch
10 * @task customfield Integration with CustomField
12 * @task order Result Ordering
13 * @task edgelogic Working with Edge Logic
14 * @task spaces Working with Spaces
16 abstract class PhabricatorCursorPagedPolicyAwareQuery
17 extends PhabricatorPolicyAwareQuery
{
19 private $externalCursorString;
20 private $internalCursorObject;
21 private $isQueryOrderReversed = false;
22 private $rawCursorRow;
24 private $applicationSearchConstraints = array();
25 private $internalPaging;
28 private $builtinOrder;
29 private $edgeLogicConstraints = array();
30 private $edgeLogicConstraintsAreValid = false;
32 private $spaceIsArchived;
33 private $ngrams = array();
34 private $ferretEngine;
35 private $ferretTokens = array();
36 private $ferretTables = array();
38 private $ferretMetadata = array();
41 const FULLTEXT_RANK
= '_ft_rank';
42 const FULLTEXT_MODIFIED
= '_ft_epochModified';
43 const FULLTEXT_CREATED
= '_ft_epochCreated';
45 /* -( Cursors )------------------------------------------------------------ */
47 protected function newExternalCursorStringForResult($object) {
48 if (!($object instanceof LiskDAO
)) {
51 'Expected to be passed a result object of class "LiskDAO" in '.
52 '"newExternalCursorStringForResult()", actually passed "%s". '.
53 'Return storage objects from "loadPage()" or override '.
54 '"newExternalCursorStringForResult()".',
55 phutil_describe_type($object)));
58 return (string)$object->getID();
61 protected function newInternalCursorFromExternalCursor($cursor) {
62 $viewer = $this->getViewer();
64 $query = newv(get_class($this), array());
67 ->setParentQuery($this)
70 // We're copying our order vector to the subquery so that the subquery
71 // knows it should generate any supplemental information required by the
74 // For example, Phriction documents may be ordered by title, but the title
75 // isn't a column in the "document" table: the query must JOIN the
76 // "content" table to perform the ordering. Passing the ordering to the
77 // subquery tells it that we need it to do that JOIN and attach relevant
78 // paging information to the internal cursor object.
80 // We only expect to load a single result, so the actual result order does
81 // not matter. We only want the internal cursor for that result to look
82 // like a cursor this parent query would generate.
83 $query->setOrderVector($this->getOrderVector());
85 $this->applyExternalCursorConstraintsToQuery($query, $cursor);
87 // If we have a Ferret fulltext query, copy it to the subquery so that we
88 // generate ranking columns appropriately, and compute the correct object
89 // ranking score for the current query.
90 if ($this->ferretEngine
) {
91 $query->withFerretConstraint($this->ferretEngine
, $this->ferretTokens
);
94 // We're executing the subquery normally to make sure the viewer can
95 // actually see the object, and that it's a completely valid object which
96 // passes all filtering and policy checks. You aren't allowed to use an
97 // object you can't see as a cursor, since this can leak information.
98 $result = $query->executeOne();
100 $this->throwCursorException(
102 'Cursor "%s" does not identify a valid object in query "%s".',
107 // Now that we made sure the viewer can actually see the object the
108 // external cursor identifies, return the internal cursor the query
109 // generated as a side effect while loading the object.
110 return $query->getInternalCursorObject();
113 final protected function throwCursorException($message) {
114 throw new PhabricatorInvalidQueryCursorException($message);
117 protected function applyExternalCursorConstraintsToQuery(
118 PhabricatorCursorPagedPolicyAwareQuery
$subquery,
120 $subquery->withIDs(array($cursor));
123 protected function newPagingMapFromCursorObject(
124 PhabricatorQueryCursor
$cursor,
127 $object = $cursor->getObject();
129 return $this->newPagingMapFromPartialObject($object);
132 protected function newPagingMapFromPartialObject($object) {
134 'id' => (int)$object->getID(),
138 private function getExternalCursorStringForResult($object) {
139 $cursor = $this->newExternalCursorStringForResult($object);
141 if (!is_string($cursor)) {
144 'Expected "newExternalCursorStringForResult()" in class "%s" to '.
145 'return a string, but got "%s".',
147 phutil_describe_type($cursor)));
153 final protected function getExternalCursorString() {
154 return $this->externalCursorString
;
157 private function setExternalCursorString($external_cursor) {
158 $this->externalCursorString
= $external_cursor;
162 final protected function getIsQueryOrderReversed() {
163 return $this->isQueryOrderReversed
;
166 final protected function setIsQueryOrderReversed($is_reversed) {
167 $this->isQueryOrderReversed
= $is_reversed;
171 private function getInternalCursorObject() {
172 return $this->internalCursorObject
;
175 private function setInternalCursorObject(
176 PhabricatorQueryCursor
$cursor) {
177 $this->internalCursorObject
= $cursor;
181 private function getInternalCursorFromExternalCursor(
184 $cursor_object = $this->newInternalCursorFromExternalCursor($cursor_string);
186 if (!($cursor_object instanceof PhabricatorQueryCursor
)) {
189 'Expected "newInternalCursorFromExternalCursor()" to return an '.
190 'object of class "PhabricatorQueryCursor", but got "%s" (in '.
192 phutil_describe_type($cursor_object),
196 return $cursor_object;
199 private function getPagingMapFromCursorObject(
200 PhabricatorQueryCursor
$cursor,
203 $map = $this->newPagingMapFromCursorObject($cursor, $keys);
205 if (!is_array($map)) {
208 'Expected "newPagingMapFromCursorObject()" to return a map of '.
209 'paging values, but got "%s" (in class "%s").',
210 phutil_describe_type($map),
214 if ($this->supportsFerretEngine()) {
215 if ($this->hasFerretOrder()) {
218 $cursor->getRawRowProperty(self
::FULLTEXT_RANK
),
219 'fulltext-modified' =>
220 $cursor->getRawRowProperty(self
::FULLTEXT_MODIFIED
),
221 'fulltext-created' =>
222 $cursor->getRawRowProperty(self
::FULLTEXT_CREATED
),
227 foreach ($keys as $key) {
228 if (!array_key_exists($key, $map)) {
231 'Map returned by "newPagingMapFromCursorObject()" in class "%s" '.
232 'omits required key "%s".',
241 final protected function nextPage(array $page) {
246 $cursor = id(new PhabricatorQueryCursor())
247 ->setObject(last($page));
249 if ($this->rawCursorRow
) {
250 $cursor->setRawRow($this->rawCursorRow
);
253 $this->setInternalCursorObject($cursor);
256 final public function getFerretMetadata() {
257 if (!$this->supportsFerretEngine()) {
260 'Unable to retrieve Ferret engine metadata, this class ("%s") does '.
261 'not support the Ferret engine.',
265 return $this->ferretMetadata
;
268 protected function loadStandardPage(PhabricatorLiskDAO
$table) {
269 $rows = $this->loadStandardPageRows($table);
270 return $table->loadAllFromArray($rows);
273 protected function loadStandardPageRows(PhabricatorLiskDAO
$table) {
274 $conn = $table->establishConnection('r');
275 return $this->loadStandardPageRowsWithConnection(
277 $table->getTableName());
280 protected function loadStandardPageRowsWithConnection(
281 AphrontDatabaseConnection
$conn,
284 $query = $this->buildStandardPageQuery($conn, $table_name);
286 $rows = queryfx_all($conn, '%Q', $query);
287 $rows = $this->didLoadRawRows($rows);
292 protected function buildStandardPageQuery(
293 AphrontDatabaseConnection
$conn,
296 $table_alias = $this->getPrimaryTableAlias();
297 if ($table_alias === null) {
298 $table_alias = qsprintf($conn, '');
300 $table_alias = qsprintf($conn, '%T', $table_alias);
305 '%Q FROM %T %Q %Q %Q %Q %Q %Q %Q',
306 $this->buildSelectClause($conn),
309 $this->buildJoinClause($conn),
310 $this->buildWhereClause($conn),
311 $this->buildGroupClause($conn),
312 $this->buildHavingClause($conn),
313 $this->buildOrderClause($conn),
314 $this->buildLimitClause($conn));
317 protected function didLoadRawRows(array $rows) {
318 $this->rawCursorRow
= last($rows);
320 if ($this->ferretEngine
) {
321 foreach ($rows as $row) {
322 $phid = $row['phid'];
324 $metadata = id(new PhabricatorFerretMetadata())
326 ->setEngine($this->ferretEngine
)
327 ->setRelevance(idx($row, self
::FULLTEXT_RANK
));
329 $this->ferretMetadata
[$phid] = $metadata;
331 unset($row[self
::FULLTEXT_RANK
]);
332 unset($row[self
::FULLTEXT_MODIFIED
]);
333 unset($row[self
::FULLTEXT_CREATED
]);
340 final protected function buildLimitClause(AphrontDatabaseConnection
$conn) {
341 if ($this->shouldLimitResults()) {
342 $limit = $this->getRawResultLimit();
344 return qsprintf($conn, 'LIMIT %d', $limit);
348 return qsprintf($conn, '');
351 protected function shouldLimitResults() {
355 final protected function didLoadResults(array $results) {
356 if ($this->getIsQueryOrderReversed()) {
357 $results = array_reverse($results, $preserve_keys = true);
363 final public function newIterator() {
364 return new PhabricatorQueryIterator($this);
367 final public function executeWithCursorPager(AphrontCursorPagerView
$pager) {
368 $limit = $pager->getPageSize();
370 $this->setLimit($limit +
1);
372 if (strlen($pager->getAfterID())) {
373 $this->setExternalCursorString($pager->getAfterID());
374 } else if ($pager->getBeforeID()) {
375 $this->setExternalCursorString($pager->getBeforeID());
376 $this->setIsQueryOrderReversed(true);
379 $results = $this->execute();
380 $count = count($results);
382 $sliced_results = $pager->sliceResults($results);
383 if ($sliced_results) {
385 // If we have results, generate external-facing cursors from the visible
386 // results. This stops us from leaking any internal details about objects
387 // which we loaded but which were not visible to the viewer.
389 if ($pager->getBeforeID() ||
($count > $limit)) {
390 $last_object = last($sliced_results);
391 $cursor = $this->getExternalCursorStringForResult($last_object);
392 $pager->setNextPageID($cursor);
395 if ($pager->getAfterID() ||
396 ($pager->getBeforeID() && ($count > $limit))) {
397 $head_object = head($sliced_results);
398 $cursor = $this->getExternalCursorStringForResult($head_object);
399 $pager->setPrevPageID($cursor);
403 return $sliced_results;
408 * Return the alias this query uses to identify the primary table.
410 * Some automatic query constructions may need to be qualified with a table
411 * alias if the query performs joins which make column names ambiguous. If
412 * this is the case, return the alias for the primary table the query
413 * uses; generally the object table which has `id` and `phid` columns.
415 * @return string Alias for the primary table.
417 protected function getPrimaryTableAlias() {
421 public function newResultObject() {
426 /* -( Building Query Clauses )--------------------------------------------- */
432 protected function buildSelectClause(AphrontDatabaseConnection
$conn) {
433 $parts = $this->buildSelectClauseParts($conn);
434 return $this->formatSelectClause($conn, $parts);
441 protected function buildSelectClauseParts(AphrontDatabaseConnection
$conn) {
444 $alias = $this->getPrimaryTableAlias();
446 $select[] = qsprintf($conn, '%T.*', $alias);
448 $select[] = qsprintf($conn, '*');
451 $select[] = $this->buildEdgeLogicSelectClause($conn);
452 $select[] = $this->buildFerretSelectClause($conn);
461 protected function buildJoinClause(AphrontDatabaseConnection
$conn) {
462 $joins = $this->buildJoinClauseParts($conn);
463 return $this->formatJoinClause($conn, $joins);
470 protected function buildJoinClauseParts(AphrontDatabaseConnection
$conn) {
472 $joins[] = $this->buildEdgeLogicJoinClause($conn);
473 $joins[] = $this->buildApplicationSearchJoinClause($conn);
474 $joins[] = $this->buildNgramsJoinClause($conn);
475 $joins[] = $this->buildFerretJoinClause($conn);
483 protected function buildWhereClause(AphrontDatabaseConnection
$conn) {
484 $where = $this->buildWhereClauseParts($conn);
485 return $this->formatWhereClause($conn, $where);
492 protected function buildWhereClauseParts(AphrontDatabaseConnection
$conn) {
494 $where[] = $this->buildPagingWhereClause($conn);
495 $where[] = $this->buildEdgeLogicWhereClause($conn);
496 $where[] = $this->buildSpacesWhereClause($conn);
497 $where[] = $this->buildNgramsWhereClause($conn);
498 $where[] = $this->buildFerretWhereClause($conn);
499 $where[] = $this->buildApplicationSearchWhereClause($conn);
507 protected function buildHavingClause(AphrontDatabaseConnection
$conn) {
508 $having = $this->buildHavingClauseParts($conn);
509 $having[] = $this->buildPagingHavingClause($conn);
510 return $this->formatHavingClause($conn, $having);
517 protected function buildHavingClauseParts(AphrontDatabaseConnection
$conn) {
519 $having[] = $this->buildEdgeLogicHavingClause($conn);
527 protected function buildGroupClause(AphrontDatabaseConnection
$conn) {
528 if (!$this->shouldGroupQueryResultRows()) {
529 return qsprintf($conn, '');
535 $this->getApplicationSearchObjectPHIDColumn($conn));
542 protected function shouldGroupQueryResultRows() {
543 if ($this->shouldGroupEdgeLogicResultRows()) {
547 if ($this->getApplicationSearchMayJoinMultipleRows()) {
551 if ($this->shouldGroupNgramResultRows()) {
555 if ($this->shouldGroupFerretResultRows()) {
564 /* -( Paging )------------------------------------------------------------- */
567 private function buildPagingWhereClause(AphrontDatabaseConnection
$conn) {
568 if ($this->shouldPageWithHavingClause()) {
572 return $this->buildPagingClause($conn);
575 private function buildPagingHavingClause(AphrontDatabaseConnection
$conn) {
576 if (!$this->shouldPageWithHavingClause()) {
580 return $this->buildPagingClause($conn);
583 private function shouldPageWithHavingClause() {
584 // If any of the paging conditions reference dynamic columns, we need to
585 // put the paging conditions in a "HAVING" clause instead of a "WHERE"
588 // For example, this happens when paging on the Ferret "rank" column,
589 // since the "rank" value is computed dynamically in the SELECT statement.
591 $orderable = $this->getOrderableColumns();
592 $vector = $this->getOrderVector();
594 foreach ($vector as $order) {
595 $key = $order->getOrderKey();
596 $column = $orderable[$key];
598 if (!empty($column['having'])) {
609 protected function buildPagingClause(AphrontDatabaseConnection
$conn) {
610 $orderable = $this->getOrderableColumns();
611 $vector = $this->getQueryableOrderVector();
613 // If we don't have a cursor object yet, it means we're trying to load
614 // the first result page. We may need to build a cursor object from the
615 // external string, or we may not need a paging clause yet.
616 $cursor_object = $this->getInternalCursorObject();
617 if (!$cursor_object) {
618 $external_cursor = $this->getExternalCursorString();
619 if ($external_cursor !== null) {
620 $cursor_object = $this->getInternalCursorFromExternalCursor(
625 // If we still don't have a cursor object, this is the first result page
626 // and we aren't paging it. We don't need to build a paging clause.
627 if (!$cursor_object) {
628 return qsprintf($conn, '');
631 $reversed = $this->getIsQueryOrderReversed();
634 foreach ($vector as $order) {
635 $keys[] = $order->getOrderKey();
637 $keys = array_fuse($keys);
639 $value_map = $this->getPagingMapFromCursorObject(
644 foreach ($vector as $order) {
645 $key = $order->getOrderKey();
647 $column = $orderable[$key];
648 $column['value'] = $value_map[$key];
650 // If the vector component is reversed, we need to reverse whatever the
651 // order of the column is.
652 if ($order->getIsReversed()) {
653 $column['reverse'] = !idx($column, 'reverse', false);
656 $columns[] = $column;
659 return $this->buildPagingClauseFromMultipleColumns(
663 'reversed' => $reversed,
669 * Simplifies the task of constructing a paging clause across multiple
670 * columns. In the general case, this looks like:
672 * A > a OR (A = a AND B > b) OR (A = a AND B = b AND C > c)
674 * To build a clause, specify the name, type, and value of each column
677 * $this->buildPagingClauseFromMultipleColumns(
682 * 'column' => 'title',
683 * 'type' => 'string',
684 * 'value' => $cursor->getTitle(),
691 * 'value' => $cursor->getID(),
695 * 'reversed' => $is_reversed,
698 * This method will then return a composable clause for inclusion in WHERE.
700 * @param AphrontDatabaseConnection Connection query will execute on.
701 * @param list<map> Column description dictionaries.
702 * @param map Additional construction options.
703 * @return string Query clause.
706 final protected function buildPagingClauseFromMultipleColumns(
707 AphrontDatabaseConnection
$conn,
711 foreach ($columns as $column) {
712 PhutilTypeSpec
::checkMap(
715 'table' => 'optional string|null',
716 'column' => 'string',
719 'reverse' => 'optional bool',
720 'unique' => 'optional bool',
721 'null' => 'optional string|null',
722 'requires-ferret' => 'optional bool',
723 'having' => 'optional bool',
727 PhutilTypeSpec
::checkMap(
730 'reversed' => 'optional bool',
733 $is_query_reversed = idx($options, 'reversed', false);
736 $accumulated = array();
737 $last_key = last_key($columns);
738 foreach ($columns as $key => $column) {
739 $type = $column['type'];
741 $null = idx($column, 'null');
742 if ($column['value'] === null) {
748 'Column "%s" has null value, but does not specify a null '.
755 $value = qsprintf($conn, '%d', $column['value']);
758 $value = qsprintf($conn, '%f', $column['value']);
761 $value = qsprintf($conn, '%s', $column['value']);
766 'Column "%s" has unknown column type "%s".',
772 $is_column_reversed = idx($column, 'reverse', false);
773 $reverse = ($is_query_reversed xor $is_column_reversed);
775 $clause = $accumulated;
777 $table_name = idx($column, 'table');
778 $column_name = $column['column'];
779 if ($table_name !== null) {
780 $field = qsprintf($conn, '%T.%T', $table_name, $column_name);
782 $field = qsprintf($conn, '%T', $column_name);
787 $can_page_if_null = ($null === 'head');
788 $can_page_if_nonnull = ($null === 'tail');
791 $can_page_if_null = !$can_page_if_null;
792 $can_page_if_nonnull = !$can_page_if_nonnull;
796 if ($can_page_if_null && $value === null) {
801 } else if ($can_page_if_nonnull && $value !== null) {
809 if ($value !== null) {
814 $reverse ?
qsprintf($conn, '>') : qsprintf($conn, '<'),
819 $clause[] = qsprintf($conn, '%LO', $parts);
823 $clauses[] = qsprintf($conn, '%LA', $clause);
826 if ($value === null) {
827 $accumulated[] = qsprintf(
832 $accumulated[] = qsprintf(
841 return qsprintf($conn, '%LO', $clauses);
844 return qsprintf($conn, '');
848 /* -( Result Ordering )---------------------------------------------------- */
852 * Select a result ordering.
854 * This is a high-level method which selects an ordering from a predefined
855 * list of builtin orders, as provided by @{method:getBuiltinOrders}. These
856 * options are user-facing and not exhaustive, but are generally convenient
859 * You can also use @{method:setOrderVector} to specify a low-level ordering
860 * across individual orderable columns. This offers greater control but is
861 * also more involved.
863 * @param string Key of a builtin order supported by this query.
867 public function setOrder($order) {
868 $aliases = $this->getBuiltinOrderAliasMap();
870 if (empty($aliases[$order])) {
873 'Query "%s" does not support a builtin order "%s". Supported orders '.
877 implode(', ', array_keys($aliases))));
880 $this->builtinOrder
= $aliases[$order];
881 $this->orderVector
= null;
888 * Set a grouping order to apply before primary result ordering.
890 * This allows you to preface the query order vector with additional orders,
891 * so you can effect "group by" queries while still respecting "order by".
893 * This is a high-level method which works alongside @{method:setOrder}. For
894 * lower-level control over order vectors, use @{method:setOrderVector}.
896 * @param PhabricatorQueryOrderVector|list<string> List of order keys.
900 public function setGroupVector($vector) {
901 $this->groupVector
= $vector;
902 $this->orderVector
= null;
909 * Get builtin orders for this class.
911 * In application UIs, we want to be able to present users with a small
912 * selection of meaningful order options (like "Order by Title") rather than
913 * an exhaustive set of column ordering options.
915 * Meaningful user-facing orders are often really orders across multiple
916 * columns: for example, a "title" ordering is usually implemented as a
917 * "title, id" ordering under the hood.
919 * Builtin orders provide a mapping from convenient, understandable
920 * user-facing orders to implementations.
922 * A builtin order should provide these keys:
924 * - `vector` (`list<string>`): The actual order vector to use.
925 * - `name` (`string`): Human-readable order name.
927 * @return map<string, wild> Map from builtin order keys to specification.
930 public function getBuiltinOrders() {
933 'vector' => array('id'),
934 'name' => pht('Creation (Newest First)'),
935 'aliases' => array('created'),
938 'vector' => array('-id'),
939 'name' => pht('Creation (Oldest First)'),
943 $object = $this->newResultObject();
944 if ($object instanceof PhabricatorCustomFieldInterface
) {
945 $list = PhabricatorCustomField
::getObjectFields(
947 PhabricatorCustomField
::ROLE_APPLICATIONSEARCH
);
948 foreach ($list->getFields() as $field) {
949 $index = $field->buildOrderIndex();
954 $legacy_key = 'custom:'.$field->getFieldKey();
955 $modern_key = $field->getModernFieldKey();
957 $orders[$modern_key] = array(
958 'vector' => array($modern_key, 'id'),
959 'name' => $field->getFieldName(),
960 'aliases' => array($legacy_key),
963 $orders['-'.$modern_key] = array(
964 'vector' => array('-'.$modern_key, '-id'),
965 'name' => pht('%s (Reversed)', $field->getFieldName()),
970 if ($this->supportsFerretEngine()) {
971 $orders['relevance'] = array(
972 'vector' => array('rank', 'fulltext-modified', 'id'),
973 'name' => pht('Relevance'),
980 public function getBuiltinOrderAliasMap() {
981 $orders = $this->getBuiltinOrders();
984 foreach ($orders as $key => $order) {
987 foreach (idx($order, 'aliases', array()) as $alias) {
991 foreach ($keys as $alias) {
992 if (isset($map[$alias])) {
995 'Two builtin orders ("%s" and "%s") define the same key or '.
996 'alias ("%s"). Each order alias and key must be unique and '.
997 'identify a single order.',
1002 $map[$alias] = $key;
1011 * Set a low-level column ordering.
1013 * This is a low-level method which offers granular control over column
1014 * ordering. In most cases, applications can more easily use
1015 * @{method:setOrder} to choose a high-level builtin order.
1017 * To set an order vector, specify a list of order keys as provided by
1018 * @{method:getOrderableColumns}.
1020 * @param PhabricatorQueryOrderVector|list<string> List of order keys.
1024 public function setOrderVector($vector) {
1025 $vector = PhabricatorQueryOrderVector
::newFromVector($vector);
1027 $orderable = $this->getOrderableColumns();
1029 // Make sure that all the components identify valid columns.
1031 foreach ($vector as $order) {
1032 $key = $order->getOrderKey();
1033 if (empty($orderable[$key])) {
1034 $valid = implode(', ', array_keys($orderable));
1035 throw new Exception(
1037 'This query ("%s") does not support sorting by order key "%s". '.
1038 'Supported orders are: %s.',
1044 $unique[$key] = idx($orderable[$key], 'unique', false);
1047 // Make sure that the last column is unique so that this is a strong
1048 // ordering which can be used for paging.
1049 $last = last($unique);
1050 if ($last !== true) {
1051 throw new Exception(
1053 'Order vector "%s" is invalid: the last column in an order must '.
1054 'be a column with unique values, but "%s" is not unique.',
1055 $vector->getAsString(),
1056 last_key($unique)));
1059 // Make sure that other columns are not unique; an ordering like "id, name"
1060 // does not make sense because only "id" can ever have an effect.
1062 foreach ($unique as $key => $is_unique) {
1064 throw new Exception(
1066 'Order vector "%s" is invalid: only the last column in an order '.
1067 'may be unique, but "%s" is a unique column and not the last '.
1068 'column in the order.',
1069 $vector->getAsString(),
1074 $this->orderVector
= $vector;
1080 * Get the effective order vector.
1082 * @return PhabricatorQueryOrderVector Effective vector.
1085 protected function getOrderVector() {
1086 if (!$this->orderVector
) {
1087 if ($this->builtinOrder
!== null) {
1088 $builtin_order = idx($this->getBuiltinOrders(), $this->builtinOrder
);
1089 $vector = $builtin_order['vector'];
1091 $vector = $this->getDefaultOrderVector();
1094 if ($this->groupVector
) {
1095 $group = PhabricatorQueryOrderVector
::newFromVector($this->groupVector
);
1096 $group->appendVector($vector);
1100 $vector = PhabricatorQueryOrderVector
::newFromVector($vector);
1102 // We call setOrderVector() here to apply checks to the default vector.
1103 // This catches any errors in the implementation.
1104 $this->setOrderVector($vector);
1107 return $this->orderVector
;
1114 protected function getDefaultOrderVector() {
1122 public function getOrderableColumns() {
1123 $cache = PhabricatorCaches
::getRequestCache();
1124 $class = get_class($this);
1125 $cache_key = 'query.orderablecolumns.'.$class;
1127 $columns = $cache->getKey($cache_key);
1128 if ($columns !== null) {
1134 'table' => $this->getPrimaryTableAlias(),
1142 $object = $this->newResultObject();
1143 if ($object instanceof PhabricatorCustomFieldInterface
) {
1144 $list = PhabricatorCustomField
::getObjectFields(
1146 PhabricatorCustomField
::ROLE_APPLICATIONSEARCH
);
1147 foreach ($list->getFields() as $field) {
1148 $index = $field->buildOrderIndex();
1153 $digest = $field->getFieldIndex();
1155 $key = $field->getModernFieldKey();
1157 $columns[$key] = array(
1158 'table' => 'appsearch_order_'.$digest,
1159 'column' => 'indexValue',
1160 'type' => $index->getIndexValueType(),
1162 'customfield' => true,
1163 'customfield.index.table' => $index->getTableName(),
1164 'customfield.index.key' => $digest,
1169 if ($this->supportsFerretEngine()) {
1170 $columns['rank'] = array(
1172 'column' => self
::FULLTEXT_RANK
,
1174 'requires-ferret' => true,
1177 $columns['fulltext-created'] = array(
1179 'column' => self
::FULLTEXT_CREATED
,
1181 'requires-ferret' => true,
1183 $columns['fulltext-modified'] = array(
1185 'column' => self
::FULLTEXT_MODIFIED
,
1187 'requires-ferret' => true,
1191 $cache->setKey($cache_key, $columns);
1200 final protected function buildOrderClause(
1201 AphrontDatabaseConnection
$conn,
1202 $for_union = false) {
1204 $orderable = $this->getOrderableColumns();
1205 $vector = $this->getQueryableOrderVector();
1208 foreach ($vector as $order) {
1209 $part = $orderable[$order->getOrderKey()];
1211 if ($order->getIsReversed()) {
1212 $part['reverse'] = !idx($part, 'reverse', false);
1217 return $this->formatOrderClause($conn, $parts, $for_union);
1223 private function getQueryableOrderVector() {
1224 $vector = $this->getOrderVector();
1225 $orderable = $this->getOrderableColumns();
1228 foreach ($vector as $order) {
1229 $column = $orderable[$order->getOrderKey()];
1231 // If this is a Ferret fulltext column but the query doesn't actually
1232 // have a fulltext query, we'll skip most of the Ferret stuff and won't
1233 // actually have the columns in the result set. Just skip them.
1234 if (!empty($column['requires-ferret'])) {
1235 if (!$this->getFerretTokens()) {
1240 $keep[] = $order->getAsScalar();
1243 return PhabricatorQueryOrderVector
::newFromVector($keep);
1249 protected function formatOrderClause(
1250 AphrontDatabaseConnection
$conn,
1252 $for_union = false) {
1254 $is_query_reversed = $this->getIsQueryOrderReversed();
1257 foreach ($parts as $key => $part) {
1258 $is_column_reversed = !empty($part['reverse']);
1261 if ($is_query_reversed) {
1262 $descending = !$descending;
1265 if ($is_column_reversed) {
1266 $descending = !$descending;
1269 $table = idx($part, 'table');
1271 // When we're building an ORDER BY clause for a sequence of UNION
1272 // statements, we can't refer to tables from the subqueries.
1277 $column = $part['column'];
1279 if ($table !== null) {
1280 $field = qsprintf($conn, '%T.%T', $table, $column);
1282 $field = qsprintf($conn, '%T', $column);
1285 $null = idx($part, 'null');
1289 $null_field = qsprintf($conn, '(%Q IS NULL)', $field);
1292 $null_field = qsprintf($conn, '(%Q IS NOT NULL)', $field);
1295 throw new Exception(
1297 'NULL value "%s" is invalid. Valid values are "head" and '.
1303 $sql[] = qsprintf($conn, '%Q DESC', $null_field);
1305 $sql[] = qsprintf($conn, '%Q ASC', $null_field);
1310 $sql[] = qsprintf($conn, '%Q DESC', $field);
1312 $sql[] = qsprintf($conn, '%Q ASC', $field);
1316 return qsprintf($conn, 'ORDER BY %LQ', $sql);
1320 /* -( Application Search )------------------------------------------------- */
1324 * Constrain the query with an ApplicationSearch index, requiring field values
1325 * contain at least one of the values in a set.
1327 * This constraint can build the most common types of queries, like:
1329 * - Find users with shirt sizes "X" or "XL".
1330 * - Find shoes with size "13".
1332 * @param PhabricatorCustomFieldIndexStorage Table where the index is stored.
1333 * @param string|list<string> One or more values to filter by.
1337 public function withApplicationSearchContainsConstraint(
1338 PhabricatorCustomFieldIndexStorage
$index,
1341 $values = (array)$value;
1343 $data_values = array();
1344 $constraint_values = array();
1345 foreach ($values as $value) {
1346 if ($value instanceof PhabricatorQueryConstraint
) {
1347 $constraint_values[] = $value;
1349 $data_values[] = $value;
1353 $alias = 'appsearch_'.count($this->applicationSearchConstraints
);
1355 $this->applicationSearchConstraints
[] = array(
1356 'type' => $index->getIndexValueType(),
1358 'table' => $index->getTableName(),
1359 'index' => $index->getIndexKey(),
1362 'data' => $data_values,
1363 'constraints' => $constraint_values,
1371 * Constrain the query with an ApplicationSearch index, requiring values
1372 * exist in a given range.
1374 * This constraint is useful for expressing date ranges:
1376 * - Find events between July 1st and July 7th.
1378 * The ends of the range are inclusive, so a `$min` of `3` and a `$max` of
1379 * `5` will match fields with values `3`, `4`, or `5`. Providing `null` for
1380 * either end of the range will leave that end of the constraint open.
1382 * @param PhabricatorCustomFieldIndexStorage Table where the index is stored.
1383 * @param int|null Minimum permissible value, inclusive.
1384 * @param int|null Maximum permissible value, inclusive.
1388 public function withApplicationSearchRangeConstraint(
1389 PhabricatorCustomFieldIndexStorage
$index,
1393 $index_type = $index->getIndexValueType();
1394 if ($index_type != 'int') {
1395 throw new Exception(
1397 'Attempting to apply a range constraint to a field with index type '.
1398 '"%s", expected type "%s".',
1403 $alias = 'appsearch_'.count($this->applicationSearchConstraints
);
1405 $this->applicationSearchConstraints
[] = array(
1406 'type' => $index->getIndexValueType(),
1408 'table' => $index->getTableName(),
1409 'index' => $index->getIndexKey(),
1411 'value' => array($min, $max),
1413 'constraints' => null,
1421 * Get the name of the query's primary object PHID column, for constructing
1422 * JOIN clauses. Normally (and by default) this is just `"phid"`, but it may
1423 * be something more exotic.
1425 * See @{method:getPrimaryTableAlias} if the column needs to be qualified with
1428 * @param AphrontDatabaseConnection Connection executing queries.
1429 * @return PhutilQueryString Column name.
1432 protected function getApplicationSearchObjectPHIDColumn(
1433 AphrontDatabaseConnection
$conn) {
1435 if ($this->getPrimaryTableAlias()) {
1436 return qsprintf($conn, '%T.phid', $this->getPrimaryTableAlias());
1438 return qsprintf($conn, 'phid');
1444 * Determine if the JOINs built by ApplicationSearch might cause each primary
1445 * object to return multiple result rows. Generally, this means the query
1446 * needs an extra GROUP BY clause.
1448 * @return bool True if the query may return multiple rows for each object.
1451 protected function getApplicationSearchMayJoinMultipleRows() {
1452 foreach ($this->applicationSearchConstraints
as $constraint) {
1453 $type = $constraint['type'];
1454 $value = $constraint['value'];
1455 $cond = $constraint['cond'];
1462 if (count($value) > 1) {
1467 throw new Exception(pht('Unknown index type "%s"!', $type));
1471 // NOTE: It's possible to write a custom field where multiple rows
1472 // match a range constraint, but we don't currently ship any in the
1473 // upstream and I can't immediately come up with cases where this
1474 // would make sense.
1477 throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
1486 * Construct a GROUP BY clause appropriate for ApplicationSearch constraints.
1488 * @param AphrontDatabaseConnection Connection executing the query.
1489 * @return string Group clause.
1492 protected function buildApplicationSearchGroupClause(
1493 AphrontDatabaseConnection
$conn) {
1495 if ($this->getApplicationSearchMayJoinMultipleRows()) {
1499 $this->getApplicationSearchObjectPHIDColumn($conn));
1501 return qsprintf($conn, '');
1507 * Construct a JOIN clause appropriate for applying ApplicationSearch
1510 * @param AphrontDatabaseConnection Connection executing the query.
1511 * @return string Join clause.
1514 protected function buildApplicationSearchJoinClause(
1515 AphrontDatabaseConnection
$conn) {
1518 foreach ($this->applicationSearchConstraints
as $key => $constraint) {
1519 $table = $constraint['table'];
1520 $alias = $constraint['alias'];
1521 $index = $constraint['index'];
1522 $cond = $constraint['cond'];
1523 $phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
1526 // Figure out whether we need to do a LEFT JOIN or not. We need to
1527 // LEFT JOIN if we're going to select "IS NULL" rows.
1528 $join_type = qsprintf($conn, 'JOIN');
1529 foreach ($constraint['constraints'] as $query_constraint) {
1530 $op = $query_constraint->getOperator();
1531 if ($op === PhabricatorQueryConstraint
::OPERATOR_NULL
) {
1532 $join_type = qsprintf($conn, 'LEFT JOIN');
1537 $joins[] = qsprintf(
1539 '%Q %T %T ON %T.objectPHID = %Q
1540 AND %T.indexKey = %s',
1550 list($min, $max) = $constraint['value'];
1551 if (($min === null) && ($max === null)) {
1552 // If there's no actual range constraint, just move on.
1556 if ($min === null) {
1557 $constraint_clause = qsprintf(
1559 '%T.indexValue <= %d',
1562 } else if ($max === null) {
1563 $constraint_clause = qsprintf(
1565 '%T.indexValue >= %d',
1569 $constraint_clause = qsprintf(
1571 '%T.indexValue BETWEEN %d AND %d',
1577 $joins[] = qsprintf(
1579 'JOIN %T %T ON %T.objectPHID = %Q
1580 AND %T.indexKey = %s
1588 $constraint_clause);
1591 throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
1595 $phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
1596 $orderable = $this->getOrderableColumns();
1598 $vector = $this->getOrderVector();
1599 foreach ($vector as $order) {
1600 $spec = $orderable[$order->getOrderKey()];
1601 if (empty($spec['customfield'])) {
1605 $table = $spec['customfield.index.table'];
1606 $alias = $spec['table'];
1607 $key = $spec['customfield.index.key'];
1609 $joins[] = qsprintf(
1611 'LEFT JOIN %T %T ON %T.objectPHID = %Q
1612 AND %T.indexKey = %s',
1622 return qsprintf($conn, '%LJ', $joins);
1624 return qsprintf($conn, '');
1629 * Construct a WHERE clause appropriate for applying ApplicationSearch
1632 * @param AphrontDatabaseConnection Connection executing the query.
1633 * @return list<string> Where clause parts.
1636 protected function buildApplicationSearchWhereClause(
1637 AphrontDatabaseConnection
$conn) {
1641 foreach ($this->applicationSearchConstraints
as $key => $constraint) {
1642 $alias = $constraint['alias'];
1643 $cond = $constraint['cond'];
1644 $type = $constraint['type'];
1646 $data_values = $constraint['data'];
1647 $constraint_values = $constraint['constraints'];
1649 $constraint_parts = array();
1655 $constraint_parts[] = qsprintf(
1657 '%T.indexValue IN (%Ls)',
1662 $constraint_parts[] = qsprintf(
1664 '%T.indexValue IN (%Ld)',
1669 throw new Exception(pht('Unknown index type "%s"!', $type));
1673 if ($constraint_values) {
1674 foreach ($constraint_values as $value) {
1675 $op = $value->getOperator();
1677 case PhabricatorQueryConstraint
::OPERATOR_NULL
:
1678 $constraint_parts[] = qsprintf(
1680 '%T.indexValue IS NULL',
1683 case PhabricatorQueryConstraint
::OPERATOR_ANY
:
1684 $constraint_parts[] = qsprintf(
1686 '%T.indexValue IS NOT NULL',
1690 throw new Exception(
1692 'No support for applying operator "%s" against '.
1693 'index of type "%s".',
1700 if ($constraint_parts) {
1701 $where[] = qsprintf($conn, '%LO', $constraint_parts);
1711 /* -( Integration with CustomField )--------------------------------------- */
1717 protected function getPagingValueMapForCustomFields(
1718 PhabricatorCustomFieldInterface
$object) {
1720 // We have to get the current field values on the cursor object.
1721 $fields = PhabricatorCustomField
::getObjectFields(
1723 PhabricatorCustomField
::ROLE_APPLICATIONSEARCH
);
1724 $fields->setViewer($this->getViewer());
1725 $fields->readFieldsFromStorage($object);
1728 foreach ($fields->getFields() as $field) {
1729 $map['custom:'.$field->getFieldKey()] = $field->getValueForStorage();
1739 protected function isCustomFieldOrderKey($key) {
1740 $prefix = 'custom:';
1741 return !strncmp($key, $prefix, strlen($prefix));
1745 /* -( Ferret )------------------------------------------------------------- */
1748 public function supportsFerretEngine() {
1749 $object = $this->newResultObject();
1750 return ($object instanceof PhabricatorFerretInterface
);
1753 public function withFerretQuery(
1754 PhabricatorFerretEngine
$engine,
1755 PhabricatorSavedQuery
$query) {
1757 if (!$this->supportsFerretEngine()) {
1758 throw new Exception(
1760 'Query ("%s") does not support the Ferret fulltext engine.',
1764 $this->ferretEngine
= $engine;
1765 $this->ferretQuery
= $query;
1770 public function getFerretTokens() {
1771 if (!$this->supportsFerretEngine()) {
1772 throw new Exception(
1774 'Query ("%s") does not support the Ferret fulltext engine.',
1778 return $this->ferretTokens
;
1781 public function withFerretConstraint(
1782 PhabricatorFerretEngine
$engine,
1783 array $fulltext_tokens) {
1785 if (!$this->supportsFerretEngine()) {
1786 throw new Exception(
1788 'Query ("%s") does not support the Ferret fulltext engine.',
1792 if ($this->ferretEngine
) {
1793 throw new Exception(
1795 'Query may not have multiple fulltext constraints.'));
1798 if (!$fulltext_tokens) {
1802 $this->ferretEngine
= $engine;
1803 $this->ferretTokens
= $fulltext_tokens;
1805 $op_absent = PhutilSearchQueryCompiler
::OPERATOR_ABSENT
;
1807 $default_function = $engine->getDefaultFunctionKey();
1808 $table_map = array();
1810 foreach ($this->ferretTokens
as $fulltext_token) {
1811 $raw_token = $fulltext_token->getToken();
1813 $function = $raw_token->getFunction();
1814 if ($function === null) {
1815 $function = $default_function;
1818 $function_def = $engine->getFunctionForName($function);
1820 // NOTE: The query compiler guarantees that a query can not make a
1821 // field both "present" and "absent", so it's safe to just use the
1822 // first operator we encounter to determine whether the table is
1825 $operator = $raw_token->getOperator();
1826 $is_optional = ($operator === $op_absent);
1828 if (!isset($table_map[$function])) {
1829 $alias = 'ftfield_'.$idx++
;
1830 $table_map[$function] = array(
1832 'function' => $function_def,
1833 'optional' => $is_optional,
1838 // Join the title field separately so we can rank results.
1839 $table_map['rank'] = array(
1840 'alias' => 'ft_rank',
1841 'function' => $engine->getFunctionForName('title'),
1843 // See T13345. Not every document has a title, so we want to LEFT JOIN
1844 // this table to avoid excluding documents with no title that match
1845 // the query in other fields.
1849 $this->ferretTables
= $table_map;
1854 protected function buildFerretSelectClause(AphrontDatabaseConnection
$conn) {
1857 if (!$this->supportsFerretEngine()) {
1861 if (!$this->hasFerretOrder()) {
1862 // We only need to SELECT the virtual rank/relevance columns if we're
1863 // actually sorting the results by rank.
1867 if (!$this->ferretEngine
) {
1868 $select[] = qsprintf($conn, '0 AS %T', self
::FULLTEXT_RANK
);
1869 $select[] = qsprintf($conn, '0 AS %T', self
::FULLTEXT_CREATED
);
1870 $select[] = qsprintf($conn, '0 AS %T', self
::FULLTEXT_MODIFIED
);
1874 $engine = $this->ferretEngine
;
1875 $stemmer = $engine->newStemmer();
1877 $op_sub = PhutilSearchQueryCompiler
::OPERATOR_SUBSTRING
;
1878 $op_not = PhutilSearchQueryCompiler
::OPERATOR_NOT
;
1879 $table_alias = 'ft_rank';
1882 foreach ($this->ferretTokens
as $fulltext_token) {
1883 $raw_token = $fulltext_token->getToken();
1884 $value = $raw_token->getValue();
1886 if ($raw_token->getOperator() == $op_not) {
1887 // Ignore "not" terms when ranking, since they aren't useful.
1891 if ($raw_token->getOperator() == $op_sub) {
1892 $is_substring = true;
1894 $is_substring = false;
1897 if ($is_substring) {
1898 $parts[] = qsprintf(
1900 'IF(%T.rawCorpus LIKE %~, 2, 0)',
1906 if ($raw_token->isQuoted()) {
1908 $is_stemmed = false;
1914 $term_constraints = array();
1916 $term_value = $engine->newTermsCorpus($value);
1918 $parts[] = qsprintf(
1920 'IF(%T.termCorpus LIKE %~, 2, 0)',
1925 $stem_value = $stemmer->stemToken($value);
1926 $stem_value = $engine->newTermsCorpus($stem_value);
1928 $parts[] = qsprintf(
1930 'IF(%T.normalCorpus LIKE %~, 1, 0)',
1936 $parts[] = qsprintf($conn, '%d', 0);
1938 $sum = array_shift($parts);
1939 foreach ($parts as $part) {
1947 $select[] = qsprintf(
1951 self
::FULLTEXT_RANK
);
1953 // See D20297. We select these as real columns in the result set so that
1954 // constructions like this will work:
1956 // ((SELECT ...) UNION (SELECT ...)) ORDER BY ...
1958 // If the columns aren't part of the result set, the final "ORDER BY" can
1961 $select[] = qsprintf(
1963 'ft_doc.epochCreated AS %T',
1964 self
::FULLTEXT_CREATED
);
1966 $select[] = qsprintf(
1968 'ft_doc.epochModified AS %T',
1969 self
::FULLTEXT_MODIFIED
);
1974 protected function buildFerretJoinClause(AphrontDatabaseConnection
$conn) {
1975 if (!$this->ferretEngine
) {
1979 $op_sub = PhutilSearchQueryCompiler
::OPERATOR_SUBSTRING
;
1980 $op_not = PhutilSearchQueryCompiler
::OPERATOR_NOT
;
1981 $op_absent = PhutilSearchQueryCompiler
::OPERATOR_ABSENT
;
1982 $op_present = PhutilSearchQueryCompiler
::OPERATOR_PRESENT
;
1984 $engine = $this->ferretEngine
;
1985 $stemmer = $engine->newStemmer();
1987 $ngram_table = $engine->getNgramsTableName();
1988 $ngram_engine = $this->getNgramEngine();
1991 foreach ($this->ferretTokens
as $fulltext_token) {
1992 $raw_token = $fulltext_token->getToken();
1994 $operator = $raw_token->getOperator();
1996 // If this is a negated term like "-pomegranate", don't join the ngram
1997 // table since we aren't looking for documents with this term. (We could
1998 // LEFT JOIN the table and require a NULL row, but this is probably more
1999 // trouble than it's worth.)
2000 if ($operator === $op_not) {
2004 // Neither the "present" or "absent" operators benefit from joining
2006 if ($operator === $op_absent ||
$operator === $op_present) {
2010 $value = $raw_token->getValue();
2012 $length = count(phutil_utf8v($value));
2014 if ($raw_token->getOperator() == $op_sub) {
2015 $is_substring = true;
2017 $is_substring = false;
2020 // If the user specified a substring query for a substring which is
2021 // shorter than the ngram length, we can't use the ngram index, so
2022 // don't do a join. We'll fall back to just doing LIKE on the full
2024 if ($is_substring) {
2030 if ($raw_token->isQuoted()) {
2031 $is_stemmed = false;
2036 if ($is_substring) {
2037 $ngrams = $ngram_engine->getSubstringNgramsFromString($value);
2039 $terms_value = $engine->newTermsCorpus($value);
2040 $ngrams = $ngram_engine->getTermNgramsFromString($terms_value);
2042 // If this is a stemmed term, only look for ngrams present in both the
2043 // unstemmed and stemmed variations.
2045 // Trim the boundary space characters so the stemmer recognizes this
2046 // is (or, at least, may be) a normal word and activates.
2047 $terms_value = trim($terms_value, ' ');
2048 $stem_value = $stemmer->stemToken($terms_value);
2049 $stem_ngrams = $ngram_engine->getTermNgramsFromString($stem_value);
2050 $ngrams = array_intersect($ngrams, $stem_ngrams);
2054 foreach ($ngrams as $ngram) {
2056 'table' => $ngram_table,
2062 // Remove common ngrams, like "the", which occur too frequently in
2063 // documents to be useful in constraining the query. The best ngrams
2064 // are obscure sequences which occur in very few documents.
2067 $common_ngrams = queryfx_all(
2069 'SELECT ngram FROM %T WHERE ngram IN (%Ls)',
2070 $engine->getCommonNgramsTableName(),
2071 ipull($flat, 'ngram'));
2072 $common_ngrams = ipull($common_ngrams, 'ngram', 'ngram');
2074 foreach ($flat as $key => $spec) {
2075 $ngram = $spec['ngram'];
2076 if (isset($common_ngrams[$ngram])) {
2081 // NOTE: MySQL discards trailing whitespace in CHAR(X) columns.
2082 $trim_ngram = rtrim($ngram, ' ');
2083 if (isset($common_ngrams[$trim_ngram])) {
2090 // MySQL only allows us to join a maximum of 61 tables per query. Each
2091 // ngram is going to cost us a join toward that limit, so if the user
2092 // specified a very long query string, just pick 16 of the ngrams
2094 if (count($flat) > 16) {
2096 $flat = array_slice($flat, 0, 16);
2099 $alias = $this->getPrimaryTableAlias();
2101 $phid_column = qsprintf($conn, '%T.%T', $alias, 'phid');
2103 $phid_column = qsprintf($conn, '%T', 'phid');
2106 $document_table = $engine->getDocumentTableName();
2107 $field_table = $engine->getFieldTableName();
2110 $joins[] = qsprintf(
2112 'JOIN %T ft_doc ON ft_doc.objectPHID = %Q',
2117 foreach ($flat as $spec) {
2118 $table = $spec['table'];
2119 $ngram = $spec['ngram'];
2121 $alias = 'ftngram_'.$idx++
;
2123 $joins[] = qsprintf(
2125 'JOIN %T %T ON %T.documentID = ft_doc.id AND %T.ngram = %s',
2133 $object = $this->newResultObject();
2135 throw new Exception(
2137 'Query class ("%s") must define "newResultObject()" to use '.
2138 'Ferret constraints.',
2142 // See T13511. If we have a fulltext query which uses valid field
2143 // functions, but at least one of the functions applies to a field which
2144 // the object can never have, the query can never match anything. Detect
2145 // this and return an empty result set.
2147 // (Even if the query is "field is absent" or "field does not contain
2148 // such-and-such", the interpretation is that these constraints are
2149 // not meaningful when applied to an object which can never have the
2152 $functions = ipull($this->ferretTables
, 'function');
2153 $functions = mpull($functions, null, 'getFerretFunctionName');
2154 foreach ($functions as $function) {
2155 if (!$function->supportsObject($object)) {
2156 throw new PhabricatorEmptyQueryException(
2158 'This query uses a fulltext function which this document '.
2159 'type does not support.'));
2163 foreach ($this->ferretTables
as $table) {
2164 $alias = $table['alias'];
2166 if (empty($table['optional'])) {
2167 $join_type = qsprintf($conn, 'JOIN');
2169 $join_type = qsprintf($conn, 'LEFT JOIN');
2172 $joins[] = qsprintf(
2174 '%Q %T %T ON ft_doc.id = %T.documentID
2175 AND %T.fieldKey = %s',
2181 $table['function']->getFerretFieldKey());
2187 protected function buildFerretWhereClause(AphrontDatabaseConnection
$conn) {
2188 if (!$this->ferretEngine
) {
2192 $engine = $this->ferretEngine
;
2193 $stemmer = $engine->newStemmer();
2194 $table_map = $this->ferretTables
;
2196 $op_sub = PhutilSearchQueryCompiler
::OPERATOR_SUBSTRING
;
2197 $op_not = PhutilSearchQueryCompiler
::OPERATOR_NOT
;
2198 $op_exact = PhutilSearchQueryCompiler
::OPERATOR_EXACT
;
2199 $op_absent = PhutilSearchQueryCompiler
::OPERATOR_ABSENT
;
2200 $op_present = PhutilSearchQueryCompiler
::OPERATOR_PRESENT
;
2203 $default_function = $engine->getDefaultFunctionKey();
2204 foreach ($this->ferretTokens
as $fulltext_token) {
2205 $raw_token = $fulltext_token->getToken();
2206 $value = $raw_token->getValue();
2208 $function = $raw_token->getFunction();
2209 if ($function === null) {
2210 $function = $default_function;
2213 $operator = $raw_token->getOperator();
2215 $table_alias = $table_map[$function]['alias'];
2217 // If this is a "field is present" operator, we've already implicitly
2218 // guaranteed this by JOINing the table. We don't need to do any
2220 $is_present = ($operator === $op_present);
2225 // If this is a "field is absent" operator, we just want documents
2226 // which failed to match to a row when we LEFT JOINed the table. This
2227 // means there's no index for the field.
2228 $is_absent = ($operator === $op_absent);
2230 $where[] = qsprintf(
2232 '(%T.rawCorpus IS NULL)',
2237 $is_not = ($operator === $op_not);
2239 if ($operator == $op_sub) {
2240 $is_substring = true;
2242 $is_substring = false;
2245 // If we're doing exact search, just test the raw corpus.
2246 $is_exact = ($operator === $op_exact);
2249 $where[] = qsprintf(
2251 '(%T.rawCorpus != %s)',
2255 $where[] = qsprintf(
2257 '(%T.rawCorpus = %s)',
2264 // If we're doing substring search, we just match against the raw corpus
2266 if ($is_substring) {
2268 $where[] = qsprintf(
2270 '(%T.rawCorpus NOT LIKE %~)',
2274 $where[] = qsprintf(
2276 '(%T.rawCorpus LIKE %~)',
2283 // Otherwise, we need to match against the term corpus and the normal
2284 // corpus, so that searching for "raw" does not find "strawberry".
2285 if ($raw_token->isQuoted()) {
2287 $is_stemmed = false;
2293 // Never stem negated queries, since this can exclude results users
2294 // did not mean to exclude and generally confuse things.
2296 $is_stemmed = false;
2299 $term_constraints = array();
2301 $term_value = $engine->newTermsCorpus($value);
2303 $term_constraints[] = qsprintf(
2305 '(%T.termCorpus NOT LIKE %~)',
2309 $term_constraints[] = qsprintf(
2311 '(%T.termCorpus LIKE %~)',
2317 $stem_value = $stemmer->stemToken($value);
2318 $stem_value = $engine->newTermsCorpus($stem_value);
2320 $term_constraints[] = qsprintf(
2322 '(%T.normalCorpus LIKE %~)',
2328 $where[] = qsprintf(
2332 } else if ($is_quoted) {
2333 $where[] = qsprintf(
2335 '(%T.rawCorpus LIKE %~ AND %LO)',
2340 $where[] = qsprintf(
2347 if ($this->ferretQuery
) {
2348 $query = $this->ferretQuery
;
2350 $author_phids = $query->getParameter('authorPHIDs');
2351 if ($author_phids) {
2352 $where[] = qsprintf(
2354 'ft_doc.authorPHID IN (%Ls)',
2358 $with_unowned = $query->getParameter('withUnowned');
2359 $with_any = $query->getParameter('withAnyOwner');
2361 if ($with_any && $with_unowned) {
2362 throw new PhabricatorEmptyQueryException(
2364 'This query matches only unowned documents owned by anyone, '.
2365 'which is impossible.'));
2368 $owner_phids = $query->getParameter('ownerPHIDs');
2369 if ($owner_phids && !$with_any) {
2370 if ($with_unowned) {
2371 $where[] = qsprintf(
2373 'ft_doc.ownerPHID IN (%Ls) OR ft_doc.ownerPHID IS NULL',
2376 $where[] = qsprintf(
2378 'ft_doc.ownerPHID IN (%Ls)',
2381 } else if ($with_unowned) {
2382 $where[] = qsprintf(
2384 'ft_doc.ownerPHID IS NULL');
2388 $where[] = qsprintf(
2390 'ft_doc.ownerPHID IS NOT NULL');
2393 $rel_open = PhabricatorSearchRelationship
::RELATIONSHIP_OPEN
;
2395 $statuses = $query->getParameter('statuses');
2398 $statuses = array_fuse($statuses);
2399 if (count($statuses) == 1) {
2400 if (isset($statuses[$rel_open])) {
2408 if ($is_closed !== null) {
2409 $where[] = qsprintf(
2411 'ft_doc.isClosed = %d',
2419 protected function shouldGroupFerretResultRows() {
2420 return (bool)$this->ferretTokens
;
2424 /* -( Ngrams )------------------------------------------------------------- */
2427 protected function withNgramsConstraint(
2428 PhabricatorSearchNgrams
$index,
2431 if (strlen($value)) {
2432 $this->ngrams
[] = array(
2435 'length' => count(phutil_utf8v($value)),
2443 protected function buildNgramsJoinClause(AphrontDatabaseConnection
$conn) {
2444 $ngram_engine = $this->getNgramEngine();
2447 foreach ($this->ngrams
as $spec) {
2448 $length = $spec['length'];
2454 $index = $spec['index'];
2455 $value = $spec['value'];
2457 $ngrams = $ngram_engine->getSubstringNgramsFromString($value);
2459 foreach ($ngrams as $ngram) {
2461 'table' => $index->getTableName(),
2471 // MySQL only allows us to join a maximum of 61 tables per query. Each
2472 // ngram is going to cost us a join toward that limit, so if the user
2473 // specified a very long query string, just pick 16 of the ngrams
2475 if (count($flat) > 16) {
2477 $flat = array_slice($flat, 0, 16);
2480 $alias = $this->getPrimaryTableAlias();
2482 $id_column = qsprintf($conn, '%T.%T', $alias, 'id');
2484 $id_column = qsprintf($conn, '%T', 'id');
2489 foreach ($flat as $spec) {
2490 $table = $spec['table'];
2491 $ngram = $spec['ngram'];
2493 $alias = 'ngm'.$idx++
;
2495 $joins[] = qsprintf(
2497 'JOIN %T %T ON %T.objectID = %Q AND %T.ngram = %s',
2510 protected function buildNgramsWhereClause(AphrontDatabaseConnection
$conn) {
2513 $ngram_engine = $this->getNgramEngine();
2515 foreach ($this->ngrams
as $ngram) {
2516 $index = $ngram['index'];
2517 $value = $ngram['value'];
2519 $column = $index->getColumnName();
2520 $alias = $this->getPrimaryTableAlias();
2522 $column = qsprintf($conn, '%T.%T', $alias, $column);
2524 $column = qsprintf($conn, '%T', $column);
2527 $tokens = $ngram_engine->tokenizeNgramString($value);
2529 foreach ($tokens as $token) {
2530 $where[] = qsprintf(
2542 protected function shouldGroupNgramResultRows() {
2543 return (bool)$this->ngrams
;
2546 private function getNgramEngine() {
2547 if (!$this->ngramEngine
) {
2548 $this->ngramEngine
= new PhabricatorSearchNgramEngine();
2551 return $this->ngramEngine
;
2555 /* -( Edge Logic )--------------------------------------------------------- */
2559 * Convenience method for specifying edge logic constraints with a list of
2562 * @param const Edge constant.
2563 * @param const Constraint operator.
2564 * @param list<phid> List of PHIDs.
2568 public function withEdgeLogicPHIDs($edge_type, $operator, array $phids) {
2569 $constraints = array();
2570 foreach ($phids as $phid) {
2571 $constraints[] = new PhabricatorQueryConstraint($operator, $phid);
2574 return $this->withEdgeLogicConstraints($edge_type, $constraints);
2582 public function withEdgeLogicConstraints($edge_type, array $constraints) {
2583 assert_instances_of($constraints, 'PhabricatorQueryConstraint');
2585 $constraints = mgroup($constraints, 'getOperator');
2586 foreach ($constraints as $operator => $list) {
2587 foreach ($list as $item) {
2588 $this->edgeLogicConstraints
[$edge_type][$operator][] = $item;
2592 $this->edgeLogicConstraintsAreValid
= false;
2601 public function buildEdgeLogicSelectClause(AphrontDatabaseConnection
$conn) {
2604 $this->validateEdgeLogicConstraints();
2606 foreach ($this->edgeLogicConstraints
as $type => $constraints) {
2607 foreach ($constraints as $operator => $list) {
2608 $alias = $this->getEdgeLogicTableAlias($operator, $type);
2609 switch ($operator) {
2610 case PhabricatorQueryConstraint
::OPERATOR_AND
:
2611 if (count($list) > 1) {
2612 $select[] = qsprintf(
2614 'COUNT(DISTINCT(%T.dst)) %T',
2616 $this->buildEdgeLogicTableAliasCount($alias));
2619 case PhabricatorQueryConstraint
::OPERATOR_ANCESTOR
:
2620 // This is tricky. We have a query which specifies multiple
2621 // projects, each of which may have an arbitrarily large number
2624 // Suppose the projects are "Engineering" and "Operations", and
2625 // "Engineering" has subprojects X, Y and Z.
2627 // We first use `FIELD(dst, X, Y, Z)` to produce a 0 if a row
2628 // is not part of Engineering at all, or some number other than
2631 // Then we use `IF(..., idx, NULL)` to convert the 0 to a NULL and
2632 // any other value to an index (say, 1) for the ancestor.
2634 // We build these up for every ancestor, then use `COALESCE(...)`
2635 // to select the non-null one, giving us an ancestor which this
2636 // row is a member of.
2638 // From there, we use `COUNT(DISTINCT(...))` to make sure that
2639 // each result row is a member of all ancestors.
2640 if (count($list) > 1) {
2643 foreach ($list as $constraint) {
2644 $parts[] = qsprintf(
2646 'IF(FIELD(%T.dst, %Ls) != 0, %d, NULL)',
2648 (array)$constraint->getValue(),
2651 $parts = qsprintf($conn, '%LQ', $parts);
2653 $select[] = qsprintf(
2655 'COUNT(DISTINCT(COALESCE(%Q))) %T',
2657 $this->buildEdgeLogicTableAliasAncestor($alias));
2673 public function buildEdgeLogicJoinClause(AphrontDatabaseConnection
$conn) {
2674 $edge_table = PhabricatorEdgeConfig
::TABLE_NAME_EDGE
;
2675 $phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
2678 foreach ($this->edgeLogicConstraints
as $type => $constraints) {
2680 $op_null = PhabricatorQueryConstraint
::OPERATOR_NULL
;
2681 $has_null = isset($constraints[$op_null]);
2683 // If we're going to process an only() operator, build a list of the
2684 // acceptable set of PHIDs first. We'll only match results which have
2685 // no edges to any other PHIDs.
2686 $all_phids = array();
2687 if (isset($constraints[PhabricatorQueryConstraint
::OPERATOR_ONLY
])) {
2688 foreach ($constraints as $operator => $list) {
2689 switch ($operator) {
2690 case PhabricatorQueryConstraint
::OPERATOR_ANCESTOR
:
2691 case PhabricatorQueryConstraint
::OPERATOR_AND
:
2692 case PhabricatorQueryConstraint
::OPERATOR_OR
:
2693 foreach ($list as $constraint) {
2694 $value = (array)$constraint->getValue();
2695 foreach ($value as $v) {
2696 $all_phids[$v] = $v;
2704 foreach ($constraints as $operator => $list) {
2705 $alias = $this->getEdgeLogicTableAlias($operator, $type);
2708 foreach ($list as $constraint) {
2709 $value = (array)$constraint->getValue();
2710 foreach ($value as $v) {
2714 $phids = array_keys($phids);
2716 switch ($operator) {
2717 case PhabricatorQueryConstraint
::OPERATOR_NOT
:
2718 $joins[] = qsprintf(
2720 'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d
2721 AND %T.dst IN (%Ls)',
2731 case PhabricatorQueryConstraint
::OPERATOR_ANCESTOR
:
2732 case PhabricatorQueryConstraint
::OPERATOR_AND
:
2733 case PhabricatorQueryConstraint
::OPERATOR_OR
:
2734 // If we're including results with no matches, we have to degrade
2735 // this to a LEFT join. We'll use WHERE to select matching rows
2738 $join_type = qsprintf($conn, 'LEFT');
2740 $join_type = qsprintf($conn, '');
2743 $joins[] = qsprintf(
2745 '%Q JOIN %T %T ON %Q = %T.src AND %T.type = %d
2746 AND %T.dst IN (%Ls)',
2757 case PhabricatorQueryConstraint
::OPERATOR_NULL
:
2758 $joins[] = qsprintf(
2760 'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d',
2768 case PhabricatorQueryConstraint
::OPERATOR_ONLY
:
2769 $joins[] = qsprintf(
2771 'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d
2772 AND %T.dst NOT IN (%Ls)',
2793 public function buildEdgeLogicWhereClause(AphrontDatabaseConnection
$conn) {
2796 foreach ($this->edgeLogicConstraints
as $type => $constraints) {
2801 $op_null = PhabricatorQueryConstraint
::OPERATOR_NULL
;
2802 $has_null = isset($constraints[$op_null]);
2804 foreach ($constraints as $operator => $list) {
2805 $alias = $this->getEdgeLogicTableAlias($operator, $type);
2806 switch ($operator) {
2807 case PhabricatorQueryConstraint
::OPERATOR_NOT
:
2808 case PhabricatorQueryConstraint
::OPERATOR_ONLY
:
2814 case PhabricatorQueryConstraint
::OPERATOR_AND
:
2815 case PhabricatorQueryConstraint
::OPERATOR_OR
:
2819 '%T.dst IS NOT NULL',
2823 case PhabricatorQueryConstraint
::OPERATOR_NULL
:
2832 if ($full && $null) {
2833 $where[] = qsprintf($conn, '(%LA OR %LA)', $full, $null);
2835 foreach ($full as $condition) {
2836 $where[] = $condition;
2839 foreach ($null as $condition) {
2840 $where[] = $condition;
2852 public function buildEdgeLogicHavingClause(AphrontDatabaseConnection
$conn) {
2855 foreach ($this->edgeLogicConstraints
as $type => $constraints) {
2856 foreach ($constraints as $operator => $list) {
2857 $alias = $this->getEdgeLogicTableAlias($operator, $type);
2858 switch ($operator) {
2859 case PhabricatorQueryConstraint
::OPERATOR_AND
:
2860 if (count($list) > 1) {
2861 $having[] = qsprintf(
2864 $this->buildEdgeLogicTableAliasCount($alias),
2868 case PhabricatorQueryConstraint
::OPERATOR_ANCESTOR
:
2869 if (count($list) > 1) {
2870 $having[] = qsprintf(
2873 $this->buildEdgeLogicTableAliasAncestor($alias),
2888 public function shouldGroupEdgeLogicResultRows() {
2889 foreach ($this->edgeLogicConstraints
as $type => $constraints) {
2890 foreach ($constraints as $operator => $list) {
2891 switch ($operator) {
2892 case PhabricatorQueryConstraint
::OPERATOR_NOT
:
2893 case PhabricatorQueryConstraint
::OPERATOR_AND
:
2894 case PhabricatorQueryConstraint
::OPERATOR_OR
:
2895 if (count($list) > 1) {
2899 case PhabricatorQueryConstraint
::OPERATOR_ANCESTOR
:
2900 // NOTE: We must always group query results rows when using an
2901 // "ANCESTOR" operator because a single task may be related to
2902 // two different descendants of a particular ancestor. For
2903 // discussion, see T12753.
2905 case PhabricatorQueryConstraint
::OPERATOR_NULL
:
2906 case PhabricatorQueryConstraint
::OPERATOR_ONLY
:
2919 private function getEdgeLogicTableAlias($operator, $type) {
2920 return 'edgelogic_'.$operator.'_'.$type;
2927 private function buildEdgeLogicTableAliasCount($alias) {
2928 return $alias.'_count';
2934 private function buildEdgeLogicTableAliasAncestor($alias) {
2935 return $alias.'_ancestor';
2940 * Select certain edge logic constraint values.
2944 protected function getEdgeLogicValues(
2950 $constraint_lists = $this->edgeLogicConstraints
;
2952 $constraint_lists = array_select_keys($constraint_lists, $edge_types);
2955 foreach ($constraint_lists as $type => $constraints) {
2957 $constraints = array_select_keys($constraints, $operators);
2959 foreach ($constraints as $operator => $list) {
2960 foreach ($list as $constraint) {
2961 $value = (array)$constraint->getValue();
2962 foreach ($value as $v) {
2974 * Validate edge logic constraints for the query.
2979 private function validateEdgeLogicConstraints() {
2980 if ($this->edgeLogicConstraintsAreValid
) {
2984 foreach ($this->edgeLogicConstraints
as $type => $constraints) {
2985 foreach ($constraints as $operator => $list) {
2986 switch ($operator) {
2987 case PhabricatorQueryConstraint
::OPERATOR_EMPTY
:
2988 throw new PhabricatorEmptyQueryException(
2989 pht('This query specifies an empty constraint.'));
2994 // This should probably be more modular, eventually, but we only do
2995 // project-based edge logic today.
2997 $project_phids = $this->getEdgeLogicValues(
2999 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
,
3002 PhabricatorQueryConstraint
::OPERATOR_AND
,
3003 PhabricatorQueryConstraint
::OPERATOR_OR
,
3004 PhabricatorQueryConstraint
::OPERATOR_NOT
,
3005 PhabricatorQueryConstraint
::OPERATOR_ANCESTOR
,
3007 if ($project_phids) {
3008 $projects = id(new PhabricatorProjectQuery())
3009 ->setViewer($this->getViewer())
3010 ->setParentQuery($this)
3011 ->withPHIDs($project_phids)
3013 $projects = mpull($projects, null, 'getPHID');
3014 foreach ($project_phids as $phid) {
3015 if (empty($projects[$phid])) {
3016 throw new PhabricatorEmptyQueryException(
3018 'This query is constrained by a project you do not have '.
3019 'permission to see.'));
3024 $op_and = PhabricatorQueryConstraint
::OPERATOR_AND
;
3025 $op_or = PhabricatorQueryConstraint
::OPERATOR_OR
;
3026 $op_ancestor = PhabricatorQueryConstraint
::OPERATOR_ANCESTOR
;
3028 foreach ($this->edgeLogicConstraints
as $type => $constraints) {
3029 foreach ($constraints as $operator => $list) {
3030 switch ($operator) {
3031 case PhabricatorQueryConstraint
::OPERATOR_ONLY
:
3032 if (count($list) > 1) {
3033 throw new PhabricatorEmptyQueryException(
3035 'This query specifies only() more than once.'));
3038 $have_and = idx($constraints, $op_and);
3039 $have_or = idx($constraints, $op_or);
3040 $have_ancestor = idx($constraints, $op_ancestor);
3041 if (!$have_and && !$have_or && !$have_ancestor) {
3042 throw new PhabricatorEmptyQueryException(
3044 'This query specifies only(), but no other constraints '.
3045 'which it can apply to.'));
3052 $this->edgeLogicConstraintsAreValid
= true;
3058 /* -( Spaces )------------------------------------------------------------- */
3062 * Constrain the query to return results from only specific Spaces.
3064 * Pass a list of Space PHIDs, or `null` to represent the default space. Only
3065 * results in those Spaces will be returned.
3067 * Queries are always constrained to include only results from spaces the
3068 * viewer has access to.
3070 * @param list<phid|null>
3073 public function withSpacePHIDs(array $space_phids) {
3074 $object = $this->newResultObject();
3077 throw new Exception(
3079 'This query (of class "%s") does not implement newResultObject(), '.
3080 'but must implement this method to enable support for Spaces.',
3084 if (!($object instanceof PhabricatorSpacesInterface
)) {
3085 throw new Exception(
3087 'This query (of class "%s") returned an object of class "%s" from '.
3088 'getNewResultObject(), but it does not implement the required '.
3089 'interface ("%s"). Objects must implement this interface to enable '.
3093 'PhabricatorSpacesInterface'));
3096 $this->spacePHIDs
= $space_phids;
3101 public function withSpaceIsArchived($archived) {
3102 $this->spaceIsArchived
= $archived;
3108 * Constrain the query to include only results in valid Spaces.
3110 * This method builds part of a WHERE clause which considers the spaces the
3111 * viewer has access to see with any explicit constraint on spaces added by
3112 * @{method:withSpacePHIDs}.
3114 * @param AphrontDatabaseConnection Database connection.
3115 * @return string Part of a WHERE clause.
3118 private function buildSpacesWhereClause(AphrontDatabaseConnection
$conn) {
3119 $object = $this->newResultObject();
3124 if (!($object instanceof PhabricatorSpacesInterface
)) {
3128 $viewer = $this->getViewer();
3130 // If we have an omnipotent viewer and no formal space constraints, don't
3131 // emit a clause. This primarily enables older migrations to run cleanly,
3132 // without fataling because they try to match a `spacePHID` column which
3133 // does not exist yet. See T8743, T8746.
3134 if ($viewer->isOmnipotent()) {
3135 if ($this->spaceIsArchived
=== null && $this->spacePHIDs
=== null) {
3140 // See T13240. If this query raises policy exceptions, don't filter objects
3141 // in the MySQL layer. We want them to reach the application layer so we
3142 // can reject them and raise an exception.
3143 if ($this->shouldRaisePolicyExceptions()) {
3147 $space_phids = array();
3148 $include_null = false;
3150 $all = PhabricatorSpacesNamespaceQuery
::getAllSpaces();
3152 // If there are no spaces at all, implicitly give the viewer access to
3153 // the default space.
3154 $include_null = true;
3156 // Otherwise, give them access to the spaces they have permission to
3158 $viewer_spaces = PhabricatorSpacesNamespaceQuery
::getViewerSpaces(
3160 foreach ($viewer_spaces as $viewer_space) {
3161 if ($this->spaceIsArchived
!== null) {
3162 if ($viewer_space->getIsArchived() != $this->spaceIsArchived
) {
3166 $phid = $viewer_space->getPHID();
3167 $space_phids[$phid] = $phid;
3168 if ($viewer_space->getIsDefaultNamespace()) {
3169 $include_null = true;
3174 // If we have additional explicit constraints, evaluate them now.
3175 if ($this->spacePHIDs
!== null) {
3176 $explicit = array();
3177 $explicit_null = false;
3178 foreach ($this->spacePHIDs
as $phid) {
3179 if ($phid === null) {
3180 $space = PhabricatorSpacesNamespaceQuery
::getDefaultSpace();
3182 $space = idx($all, $phid);
3186 $phid = $space->getPHID();
3187 $explicit[$phid] = $phid;
3188 if ($space->getIsDefaultNamespace()) {
3189 $explicit_null = true;
3194 // If the viewer can see the default space but it isn't on the explicit
3195 // list of spaces to query, don't match it.
3196 if ($include_null && !$explicit_null) {
3197 $include_null = false;
3200 // Include only the spaces common to the viewer and the constraints.
3201 $space_phids = array_intersect_key($space_phids, $explicit);
3204 if (!$space_phids && !$include_null) {
3205 if ($this->spacePHIDs
=== null) {
3206 throw new PhabricatorEmptyQueryException(
3207 pht('You do not have access to any spaces.'));
3209 throw new PhabricatorEmptyQueryException(
3211 'You do not have access to any of the spaces this query '.
3212 'is constrained to.'));
3216 $alias = $this->getPrimaryTableAlias();
3218 $col = qsprintf($conn, '%T.spacePHID', $alias);
3220 $col = qsprintf($conn, 'spacePHID');
3223 if ($space_phids && $include_null) {
3226 '(%Q IN (%Ls) OR %Q IS NULL)',
3230 } else if ($space_phids) {
3244 private function hasFerretOrder() {
3245 $vector = $this->getOrderVector();
3247 if ($vector->containsKey('rank')) {
3251 if ($vector->containsKey('fulltext-created')) {
3255 if ($vector->containsKey('fulltext-modified')) {