@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3/**
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.
6 *
7 * @task cursors Query Cursors
8 * @task clauses Building Query Clauses
9 * @task appsearch Integration with ApplicationSearch
10 * @task customfield Integration with CustomField
11 * @task paging Paging
12 * @task order Result Ordering
13 * @task edgelogic Working with Edge Logic
14 * @task spaces Working with Spaces
15 *
16 * @template R of PhabricatorPolicyInterface
17 * @extends PhabricatorPolicyAwareQuery<R>
18 */
19abstract class PhabricatorCursorPagedPolicyAwareQuery
20 extends PhabricatorPolicyAwareQuery {
21
22 private $externalCursorString;
23 private $internalCursorObject;
24 private $isQueryOrderReversed = false;
25 private $rawCursorRow;
26
27 private $applicationSearchConstraints = array();
28 private $internalPaging;
29 private $orderVector;
30 private $groupVector;
31 private $builtinOrder;
32 private $edgeLogicConstraints = array();
33 private $edgeLogicConstraintsAreValid = false;
34 private $spacePHIDs;
35 private $spaceIsArchived;
36 private $ngrams = array();
37 private $ferretEngine;
38 private $ferretTokens = array();
39 private $ferretTables = array();
40 private $ferretQuery;
41 private $ferretMetadata = array();
42 private $ngramEngine;
43
44 const FULLTEXT_RANK = '_ft_rank';
45 const FULLTEXT_MODIFIED = '_ft_epochModified';
46 const FULLTEXT_CREATED = '_ft_epochCreated';
47
48/* -( Cursors )------------------------------------------------------------ */
49
50 protected function newExternalCursorStringForResult($object) {
51 if (!($object instanceof LiskDAO)) {
52 throw new Exception(
53 pht(
54 'Expected to be passed a result object of class "LiskDAO" in '.
55 '"newExternalCursorStringForResult()", actually passed "%s". '.
56 'Return storage objects from "loadPage()" or override '.
57 '"newExternalCursorStringForResult()".',
58 phutil_describe_type($object)));
59 }
60
61 return (string)$object->getID();
62 }
63
64 protected function newInternalCursorFromExternalCursor($cursor) {
65 $viewer = $this->getViewer();
66
67 $query = newv(get_class($this), array());
68
69 $query
70 ->setParentQuery($this)
71 ->setViewer($viewer);
72
73 // We're copying our order vector to the subquery so that the subquery
74 // knows it should generate any supplemental information required by the
75 // ordering.
76
77 // For example, Phriction documents may be ordered by title, but the title
78 // isn't a column in the "document" table: the query must JOIN the
79 // "content" table to perform the ordering. Passing the ordering to the
80 // subquery tells it that we need it to do that JOIN and attach relevant
81 // paging information to the internal cursor object.
82
83 // We only expect to load a single result, so the actual result order does
84 // not matter. We only want the internal cursor for that result to look
85 // like a cursor this parent query would generate.
86 $query->setOrderVector($this->getOrderVector());
87
88 $this->applyExternalCursorConstraintsToQuery($query, $cursor);
89
90 // If we have a Ferret fulltext query, copy it to the subquery so that we
91 // generate ranking columns appropriately, and compute the correct object
92 // ranking score for the current query.
93 if ($this->ferretEngine) {
94 $query->withFerretConstraint($this->ferretEngine, $this->ferretTokens);
95 }
96
97 // We're executing the subquery normally to make sure the viewer can
98 // actually see the object, and that it's a completely valid object which
99 // passes all filtering and policy checks. You aren't allowed to use an
100 // object you can't see as a cursor, since this can leak information.
101 $result = $query->executeOne();
102 if (!$result) {
103 $this->throwCursorException(
104 pht(
105 'Cursor "%s" does not identify a valid object in query "%s".',
106 $cursor,
107 get_class($this)));
108 }
109
110 // Now that we made sure the viewer can actually see the object the
111 // external cursor identifies, return the internal cursor the query
112 // generated as a side effect while loading the object.
113 return $query->getInternalCursorObject();
114 }
115
116 final protected function throwCursorException($message) {
117 throw new PhabricatorInvalidQueryCursorException($message);
118 }
119
120 protected function applyExternalCursorConstraintsToQuery(
121 PhabricatorCursorPagedPolicyAwareQuery $subquery,
122 $cursor) {
123 $subquery->withIDs(array($cursor));
124 }
125
126 protected function newPagingMapFromCursorObject(
127 PhabricatorQueryCursor $cursor,
128 array $keys) {
129
130 $object = $cursor->getObject();
131
132 return $this->newPagingMapFromPartialObject($object);
133 }
134
135 protected function newPagingMapFromPartialObject($object) {
136 return array(
137 'id' => (int)$object->getID(),
138 );
139 }
140
141 private function getExternalCursorStringForResult($object) {
142 $cursor = $this->newExternalCursorStringForResult($object);
143
144 if (!is_string($cursor)) {
145 throw new Exception(
146 pht(
147 'Expected "newExternalCursorStringForResult()" in class "%s" to '.
148 'return a string, but got "%s".',
149 get_class($this),
150 phutil_describe_type($cursor)));
151 }
152
153 return $cursor;
154 }
155
156 final protected function getExternalCursorString() {
157 return $this->externalCursorString;
158 }
159
160 private function setExternalCursorString($external_cursor) {
161 $this->externalCursorString = $external_cursor;
162 return $this;
163 }
164
165 final protected function getIsQueryOrderReversed() {
166 return $this->isQueryOrderReversed;
167 }
168
169 final protected function setIsQueryOrderReversed($is_reversed) {
170 $this->isQueryOrderReversed = $is_reversed;
171 return $this;
172 }
173
174 private function getInternalCursorObject() {
175 return $this->internalCursorObject;
176 }
177
178 private function setInternalCursorObject(
179 PhabricatorQueryCursor $cursor) {
180 $this->internalCursorObject = $cursor;
181 return $this;
182 }
183
184 private function getInternalCursorFromExternalCursor(
185 $cursor_string) {
186
187 $cursor_object = $this->newInternalCursorFromExternalCursor($cursor_string);
188
189 if (!($cursor_object instanceof PhabricatorQueryCursor)) {
190 throw new Exception(
191 pht(
192 'Expected "newInternalCursorFromExternalCursor()" to return an '.
193 'object of class "PhabricatorQueryCursor", but got "%s" (in '.
194 'class "%s").',
195 phutil_describe_type($cursor_object),
196 get_class($this)));
197 }
198
199 return $cursor_object;
200 }
201
202 private function getPagingMapFromCursorObject(
203 PhabricatorQueryCursor $cursor,
204 array $keys) {
205
206 $map = $this->newPagingMapFromCursorObject($cursor, $keys);
207
208 if (!is_array($map)) {
209 throw new Exception(
210 pht(
211 'Expected "newPagingMapFromCursorObject()" to return a map of '.
212 'paging values, but got "%s" (in class "%s").',
213 phutil_describe_type($map),
214 get_class($this)));
215 }
216
217 if ($this->supportsFerretEngine()) {
218 if ($this->hasFerretOrder()) {
219 $map += array(
220 'rank' =>
221 $cursor->getRawRowProperty(self::FULLTEXT_RANK),
222 'fulltext-modified' =>
223 $cursor->getRawRowProperty(self::FULLTEXT_MODIFIED),
224 'fulltext-created' =>
225 $cursor->getRawRowProperty(self::FULLTEXT_CREATED),
226 );
227 }
228 }
229
230 foreach ($keys as $key) {
231 if (!array_key_exists($key, $map)) {
232 throw new Exception(
233 pht(
234 'Map returned by "newPagingMapFromCursorObject()" in class "%s" '.
235 'omits required key "%s".',
236 get_class($this),
237 $key));
238 }
239 }
240
241 return $map;
242 }
243
244 final protected function nextPage(array $page) {
245 if (!$page) {
246 return;
247 }
248
249 $cursor = id(new PhabricatorQueryCursor())
250 ->setObject(last($page));
251
252 if ($this->rawCursorRow) {
253 $cursor->setRawRow($this->rawCursorRow);
254 }
255
256 $this->setInternalCursorObject($cursor);
257 }
258
259 final public function getFerretMetadata() {
260 if (!$this->supportsFerretEngine()) {
261 throw new Exception(
262 pht(
263 'Unable to retrieve Ferret engine metadata, this class ("%s") does '.
264 'not support the Ferret engine.',
265 get_class($this)));
266 }
267
268 return $this->ferretMetadata;
269 }
270
271 protected function loadPage() {
272 $object = $this->newResultObject();
273
274 if (!$object instanceof PhabricatorLiskDAO) {
275 throw new Exception(
276 pht(
277 'Query class ("%s") did not return the correct type of object '.
278 'from "newResultObject()" (expected a subclass of '.
279 '"PhabricatorLiskDAO", found "%s"). Return an object of the '.
280 'expected type (this is common), or implement a custom '.
281 '"loadPage()" method (this is unusual in modern code).',
282 get_class($this),
283 phutil_describe_type($object)));
284 }
285
286 return $this->loadStandardPage($object);
287 }
288
289 protected function loadStandardPage(PhabricatorLiskDAO $table) {
290 $rows = $this->loadStandardPageRows($table);
291 return $table->loadAllFromArray($rows);
292 }
293
294 protected function loadStandardPageRows(PhabricatorLiskDAO $table) {
295 $conn = $table->establishConnection('r');
296 return $this->loadStandardPageRowsWithConnection(
297 $conn,
298 $table->getTableName());
299 }
300
301 protected function loadStandardPageRowsWithConnection(
302 AphrontDatabaseConnection $conn,
303 $table_name) {
304
305 $query = $this->buildStandardPageQuery($conn, $table_name);
306
307 $rows = queryfx_all($conn, '%Q', $query);
308 $rows = $this->didLoadRawRows($rows);
309
310 return $rows;
311 }
312
313 protected function buildStandardPageQuery(
314 AphrontDatabaseConnection $conn,
315 $table_name) {
316
317 $table_alias = $this->getPrimaryTableAlias();
318 if ($table_alias === null) {
319 $table_alias = qsprintf($conn, '');
320 } else {
321 $table_alias = qsprintf($conn, '%T', $table_alias);
322 }
323
324 return qsprintf(
325 $conn,
326 '%Q FROM %T %Q %Q %Q %Q %Q %Q %Q',
327 $this->buildSelectClause($conn),
328 $table_name,
329 $table_alias,
330 $this->buildJoinClause($conn),
331 $this->buildWhereClause($conn),
332 $this->buildGroupClause($conn),
333 $this->buildHavingClause($conn),
334 $this->buildOrderClause($conn),
335 $this->buildLimitClause($conn));
336 }
337
338 protected function didLoadRawRows(array $rows) {
339 $this->rawCursorRow = last($rows);
340
341 if ($this->ferretEngine) {
342 foreach ($rows as $row) {
343 $phid = $row['phid'];
344
345 $metadata = id(new PhabricatorFerretMetadata())
346 ->setPHID($phid)
347 ->setEngine($this->ferretEngine)
348 ->setRelevance(idx($row, self::FULLTEXT_RANK));
349
350 $this->ferretMetadata[$phid] = $metadata;
351
352 unset($row[self::FULLTEXT_RANK]);
353 unset($row[self::FULLTEXT_MODIFIED]);
354 unset($row[self::FULLTEXT_CREATED]);
355 }
356 }
357
358 return $rows;
359 }
360
361 final protected function buildLimitClause(AphrontDatabaseConnection $conn) {
362 if ($this->shouldLimitResults()) {
363 $limit = $this->getRawResultLimit();
364 if ($limit) {
365 return qsprintf($conn, 'LIMIT %d', $limit);
366 }
367 }
368
369 return qsprintf($conn, '');
370 }
371
372 protected function shouldLimitResults() {
373 return true;
374 }
375
376 final protected function didLoadResults(array $results) {
377 if ($this->getIsQueryOrderReversed()) {
378 $results = array_reverse($results, $preserve_keys = true);
379 }
380
381 return $results;
382 }
383
384 final public function newIterator() {
385 return new PhabricatorQueryIterator($this);
386 }
387
388 final public function executeWithCursorPager(AphrontCursorPagerView $pager) {
389 $limit = $pager->getPageSize();
390
391 $this->setLimit($limit + 1);
392
393 $after_id = phutil_string_cast($pager->getAfterID());
394 $before_id = phutil_string_cast($pager->getBeforeID());
395
396 if (phutil_nonempty_string($after_id)) {
397 $this->setExternalCursorString($after_id);
398 } else if (phutil_nonempty_string($before_id)) {
399 $this->setExternalCursorString($before_id);
400 $this->setIsQueryOrderReversed(true);
401 }
402
403 $results = $this->execute();
404 $count = count($results);
405
406 $sliced_results = $pager->sliceResults($results);
407 if ($sliced_results) {
408
409 // If we have results, generate external-facing cursors from the visible
410 // results. This stops us from leaking any internal details about objects
411 // which we loaded but which were not visible to the viewer.
412
413 if ($pager->getBeforeID() || ($count > $limit)) {
414 $last_object = last($sliced_results);
415 $cursor = $this->getExternalCursorStringForResult($last_object);
416 $pager->setNextPageID($cursor);
417 }
418
419 if ($pager->getAfterID() ||
420 ($pager->getBeforeID() && ($count > $limit))) {
421 $head_object = head($sliced_results);
422 $cursor = $this->getExternalCursorStringForResult($head_object);
423 $pager->setPrevPageID($cursor);
424 }
425 }
426
427 return $sliced_results;
428 }
429
430
431 /**
432 * Return the alias this query uses to identify the primary table.
433 *
434 * Some automatic query constructions may need to be qualified with a table
435 * alias if the query performs joins which make column names ambiguous. If
436 * this is the case, return the alias for the primary table the query
437 * uses; generally the object table which has `id` and `phid` columns.
438 *
439 * @return string|null Alias for the primary table, or null.
440 */
441 protected function getPrimaryTableAlias() {
442 return null;
443 }
444
445 /**
446 * @return R|null
447 */
448 public function newResultObject() {
449 return null;
450 }
451
452
453/* -( Building Query Clauses )--------------------------------------------- */
454
455
456 /**
457 * @task clauses
458 */
459 protected function buildSelectClause(AphrontDatabaseConnection $conn) {
460 $parts = $this->buildSelectClauseParts($conn);
461 return $this->formatSelectClause($conn, $parts);
462 }
463
464
465 /**
466 * @task clauses
467 */
468 protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) {
469 $select = array();
470
471 $alias = $this->getPrimaryTableAlias();
472 if ($alias) {
473 $select[] = qsprintf($conn, '%T.*', $alias);
474 } else {
475 $select[] = qsprintf($conn, '*');
476 }
477
478 $select[] = $this->buildEdgeLogicSelectClause($conn);
479 $select[] = $this->buildFerretSelectClause($conn);
480
481 return $select;
482 }
483
484
485 /**
486 * @task clauses
487 */
488 protected function buildJoinClause(AphrontDatabaseConnection $conn) {
489 $joins = $this->buildJoinClauseParts($conn);
490 return $this->formatJoinClause($conn, $joins);
491 }
492
493
494 /**
495 * @task clauses
496 */
497 protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) {
498 $joins = array();
499 $joins[] = $this->buildEdgeLogicJoinClause($conn);
500 $joins[] = $this->buildApplicationSearchJoinClause($conn);
501 $joins[] = $this->buildNgramsJoinClause($conn);
502 $joins[] = $this->buildFerretJoinClause($conn);
503 return $joins;
504 }
505
506
507 /**
508 * @task clauses
509 */
510 protected function buildWhereClause(AphrontDatabaseConnection $conn) {
511 $where = $this->buildWhereClauseParts($conn);
512 return $this->formatWhereClause($conn, $where);
513 }
514
515
516 /**
517 * @task clauses
518 */
519 protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
520 $where = array();
521 $where[] = $this->buildPagingWhereClause($conn);
522 $where[] = $this->buildEdgeLogicWhereClause($conn);
523 $where[] = $this->buildSpacesWhereClause($conn);
524 $where[] = $this->buildNgramsWhereClause($conn);
525 $where[] = $this->buildFerretWhereClause($conn);
526 $where[] = $this->buildApplicationSearchWhereClause($conn);
527 return $where;
528 }
529
530
531 /**
532 * @task clauses
533 */
534 protected function buildHavingClause(AphrontDatabaseConnection $conn) {
535 $having = $this->buildHavingClauseParts($conn);
536 $having[] = $this->buildPagingHavingClause($conn);
537 return $this->formatHavingClause($conn, $having);
538 }
539
540
541 /**
542 * @task clauses
543 */
544 protected function buildHavingClauseParts(AphrontDatabaseConnection $conn) {
545 $having = array();
546 $having[] = $this->buildEdgeLogicHavingClause($conn);
547 return $having;
548 }
549
550
551 /**
552 * @task clauses
553 */
554 protected function buildGroupClause(AphrontDatabaseConnection $conn) {
555 if (!$this->shouldGroupQueryResultRows()) {
556 return qsprintf($conn, '');
557 }
558
559 return qsprintf(
560 $conn,
561 'GROUP BY %Q',
562 $this->getApplicationSearchObjectPHIDColumn($conn));
563 }
564
565
566 /**
567 * @task clauses
568 */
569 protected function shouldGroupQueryResultRows() {
570 if ($this->shouldGroupEdgeLogicResultRows()) {
571 return true;
572 }
573
574 if ($this->getApplicationSearchMayJoinMultipleRows()) {
575 return true;
576 }
577
578 if ($this->shouldGroupNgramResultRows()) {
579 return true;
580 }
581
582 if ($this->shouldGroupFerretResultRows()) {
583 return true;
584 }
585
586 return false;
587 }
588
589
590
591/* -( Paging )------------------------------------------------------------- */
592
593
594 private function buildPagingWhereClause(AphrontDatabaseConnection $conn) {
595 if ($this->shouldPageWithHavingClause()) {
596 return null;
597 }
598
599 return $this->buildPagingClause($conn);
600 }
601
602 private function buildPagingHavingClause(AphrontDatabaseConnection $conn) {
603 if (!$this->shouldPageWithHavingClause()) {
604 return null;
605 }
606
607 return $this->buildPagingClause($conn);
608 }
609
610 private function shouldPageWithHavingClause() {
611 // If any of the paging conditions reference dynamic columns, we need to
612 // put the paging conditions in a "HAVING" clause instead of a "WHERE"
613 // clause.
614
615 // For example, this happens when paging on the Ferret "rank" column,
616 // since the "rank" value is computed dynamically in the SELECT statement.
617
618 $orderable = $this->getOrderableColumns();
619 $vector = $this->getOrderVector();
620
621 foreach ($vector as $order) {
622 $key = $order->getOrderKey();
623 $column = $orderable[$key];
624
625 if (!empty($column['having'])) {
626 return true;
627 }
628 }
629
630 return false;
631 }
632
633 /**
634 * @task paging
635 */
636 protected function buildPagingClause(AphrontDatabaseConnection $conn) {
637 $orderable = $this->getOrderableColumns();
638 $vector = $this->getQueryableOrderVector();
639
640 // If we don't have a cursor object yet, it means we're trying to load
641 // the first result page. We may need to build a cursor object from the
642 // external string, or we may not need a paging clause yet.
643 $cursor_object = $this->getInternalCursorObject();
644 if (!$cursor_object) {
645 $external_cursor = $this->getExternalCursorString();
646 if ($external_cursor !== null) {
647 $cursor_object = $this->getInternalCursorFromExternalCursor(
648 $external_cursor);
649 }
650 }
651
652 // If we still don't have a cursor object, this is the first result page
653 // and we aren't paging it. We don't need to build a paging clause.
654 if (!$cursor_object) {
655 return qsprintf($conn, '');
656 }
657
658 $reversed = $this->getIsQueryOrderReversed();
659
660 $keys = array();
661 foreach ($vector as $order) {
662 $keys[] = $order->getOrderKey();
663 }
664 $keys = array_fuse($keys);
665
666 $value_map = $this->getPagingMapFromCursorObject(
667 $cursor_object,
668 $keys);
669
670 $columns = array();
671 foreach ($vector as $order) {
672 $key = $order->getOrderKey();
673
674 $column = $orderable[$key];
675 $column['value'] = $value_map[$key];
676
677 // If the vector component is reversed, we need to reverse whatever the
678 // order of the column is.
679 if ($order->getIsReversed()) {
680 $column['reverse'] = !idx($column, 'reverse', false);
681 }
682
683 $columns[] = $column;
684 }
685
686 return $this->buildPagingClauseFromMultipleColumns(
687 $conn,
688 $columns,
689 array(
690 'reversed' => $reversed,
691 ));
692 }
693
694
695 /**
696 * Simplifies the task of constructing a paging clause across multiple
697 * columns. In the general case, this looks like:
698 *
699 * A > a OR (A = a AND B > b) OR (A = a AND B = b AND C > c)
700 *
701 * To build a clause, specify the name, type, and value of each column
702 * to include:
703 *
704 * $this->buildPagingClauseFromMultipleColumns(
705 * $conn_r,
706 * array(
707 * array(
708 * 'table' => 't',
709 * 'column' => 'title',
710 * 'type' => 'string',
711 * 'value' => $cursor->getTitle(),
712 * 'reverse' => true,
713 * ),
714 * array(
715 * 'table' => 't',
716 * 'column' => 'id',
717 * 'type' => 'int',
718 * 'value' => $cursor->getID(),
719 * ),
720 * ),
721 * array(
722 * 'reversed' => $is_reversed,
723 * ));
724 *
725 * This method will then return a composable clause for inclusion in WHERE.
726 *
727 * @param AphrontDatabaseConnection $conn Connection query will execute on.
728 * @param list<map> $columns Column description dictionaries.
729 * @param map $options Additional construction options.
730 * @return string Query clause.
731 * @task paging
732 */
733 final protected function buildPagingClauseFromMultipleColumns(
734 AphrontDatabaseConnection $conn,
735 array $columns,
736 array $options) {
737
738 foreach ($columns as $column) {
739 PhutilTypeSpec::checkMap(
740 $column,
741 array(
742 'table' => 'optional string|null',
743 'column' => 'string',
744 'customfield' => 'optional bool',
745 'customfield.index.key' => 'optional string',
746 'customfield.index.table' => 'optional string',
747 'value' => 'wild',
748 'type' => 'string',
749 'reverse' => 'optional bool',
750 'unique' => 'optional bool',
751 'null' => 'optional string|null',
752 'requires-ferret' => 'optional bool',
753 'having' => 'optional bool',
754 ));
755 }
756
757 PhutilTypeSpec::checkMap(
758 $options,
759 array(
760 'reversed' => 'optional bool',
761 ));
762
763 $is_query_reversed = idx($options, 'reversed', false);
764
765 $clauses = array();
766 $accumulated = array();
767 $last_key = last_key($columns);
768 foreach ($columns as $key => $column) {
769 $type = $column['type'];
770
771 $null = idx($column, 'null');
772 if ($column['value'] === null) {
773 if ($null) {
774 $value = null;
775 } else {
776 throw new Exception(
777 pht(
778 'Column "%s" has null value, but does not specify a null '.
779 'behavior.',
780 $key));
781 }
782 } else {
783 switch ($type) {
784 case 'int':
785 $value = qsprintf($conn, '%d', $column['value']);
786 break;
787 case 'float':
788 $value = qsprintf($conn, '%f', $column['value']);
789 break;
790 case 'string':
791 $value = qsprintf($conn, '%s', $column['value']);
792 break;
793 default:
794 throw new Exception(
795 pht(
796 'Column "%s" has unknown column type "%s".',
797 $column['column'],
798 $type));
799 }
800 }
801
802 $is_column_reversed = idx($column, 'reverse', false);
803 $reverse = ($is_query_reversed xor $is_column_reversed);
804
805 $clause = $accumulated;
806
807 $table_name = idx($column, 'table');
808 $column_name = $column['column'];
809 if ($table_name !== null) {
810 $field = qsprintf($conn, '%T.%T', $table_name, $column_name);
811 } else {
812 $field = qsprintf($conn, '%T', $column_name);
813 }
814
815 $parts = array();
816 if ($null) {
817 $can_page_if_null = ($null === 'head');
818 $can_page_if_nonnull = ($null === 'tail');
819
820 if ($reverse) {
821 $can_page_if_null = !$can_page_if_null;
822 $can_page_if_nonnull = !$can_page_if_nonnull;
823 }
824
825 $subclause = null;
826 if ($can_page_if_null && $value === null) {
827 $parts[] = qsprintf(
828 $conn,
829 '(%Q IS NOT NULL)',
830 $field);
831 } else if ($can_page_if_nonnull && $value !== null) {
832 $parts[] = qsprintf(
833 $conn,
834 '(%Q IS NULL)',
835 $field);
836 }
837 }
838
839 if ($value !== null) {
840 $parts[] = qsprintf(
841 $conn,
842 '%Q %Q %Q',
843 $field,
844 $reverse ? qsprintf($conn, '>') : qsprintf($conn, '<'),
845 $value);
846 }
847
848 if ($parts) {
849 $clause[] = qsprintf($conn, '%LO', $parts);
850 }
851
852 if ($clause) {
853 $clauses[] = qsprintf($conn, '%LA', $clause);
854 }
855
856 if ($value === null) {
857 $accumulated[] = qsprintf(
858 $conn,
859 '%Q IS NULL',
860 $field);
861 } else {
862 $accumulated[] = qsprintf(
863 $conn,
864 '%Q = %Q',
865 $field,
866 $value);
867 }
868 }
869
870 if ($clauses) {
871 return qsprintf($conn, '%LO', $clauses);
872 }
873
874 return qsprintf($conn, '');
875 }
876
877
878/* -( Result Ordering )---------------------------------------------------- */
879
880
881 /**
882 * Select a result ordering.
883 *
884 * This is a high-level method which selects an ordering from a predefined
885 * list of builtin orders, as provided by @{method:getBuiltinOrders}. These
886 * options are user-facing and not exhaustive, but are generally convenient
887 * and meaningful.
888 *
889 * You can also use @{method:setOrderVector} to specify a low-level ordering
890 * across individual orderable columns. This offers greater control but is
891 * also more involved.
892 *
893 * @param string $order Key of a builtin order supported by this query.
894 * @return $this
895 * @task order
896 */
897 public function setOrder($order) {
898 $aliases = $this->getBuiltinOrderAliasMap();
899
900 if (empty($aliases[$order])) {
901 throw new Exception(
902 pht(
903 'Query "%s" does not support a builtin order "%s". Supported orders '.
904 'are: %s.',
905 get_class($this),
906 $order,
907 implode(', ', array_keys($aliases))));
908 }
909
910 $this->builtinOrder = $aliases[$order];
911 $this->orderVector = null;
912
913 return $this;
914 }
915
916
917 /**
918 * Set a grouping order to apply before primary result ordering.
919 *
920 * This allows you to preface the query order vector with additional orders,
921 * so you can effect "group by" queries while still respecting "order by".
922 *
923 * This is a high-level method which works alongside @{method:setOrder}. For
924 * lower-level control over order vectors, use @{method:setOrderVector}.
925 *
926 * @param PhabricatorQueryOrderVector|list<string> $vector List of order
927 * keys.
928 * @return $this
929 * @task order
930 */
931 public function setGroupVector($vector) {
932 $this->groupVector = $vector;
933 $this->orderVector = null;
934
935 return $this;
936 }
937
938
939 /**
940 * Get builtin orders for this class.
941 *
942 * In application UIs, we want to be able to present users with a small
943 * selection of meaningful order options (like "Order by Title") rather than
944 * an exhaustive set of column ordering options.
945 *
946 * Meaningful user-facing orders are often really orders across multiple
947 * columns: for example, a "title" ordering is usually implemented as a
948 * "title, id" ordering under the hood.
949 *
950 * Builtin orders provide a mapping from convenient, understandable
951 * user-facing orders to implementations.
952 *
953 * A builtin order should provide these keys:
954 *
955 * - `vector` (`list<string>`): The actual order vector to use.
956 * - `name` (`string`): Human-readable order name.
957 *
958 * @phpstan-type BuiltinOrder array{name: string, vector: string[],
959 * aliases?: string[]}
960 * @return map<string,BuiltinOrder> Map from builtin order keys to
961 * specification.
962 *
963 * @task order
964 */
965 public function getBuiltinOrders() {
966 $orders = array(
967 'newest' => array(
968 'vector' => array('id'),
969 'name' => pht('Creation (Newest First)'),
970 'aliases' => array('created'),
971 ),
972 'oldest' => array(
973 'vector' => array('-id'),
974 'name' => pht('Creation (Oldest First)'),
975 ),
976 );
977
978 $object = $this->newResultObject();
979 if ($object instanceof PhabricatorCustomFieldInterface) {
980 $list = PhabricatorCustomField::getObjectFields(
981 $object,
982 PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
983 foreach ($list->getFields() as $field) {
984 $index = $field->buildOrderIndex();
985 if (!$index) {
986 continue;
987 }
988
989 $legacy_key = 'custom:'.$field->getFieldKey();
990 $modern_key = $field->getModernFieldKey();
991
992 $orders[$modern_key] = array(
993 'vector' => array($modern_key, 'id'),
994 'name' => $field->getFieldName(),
995 'aliases' => array($legacy_key),
996 );
997
998 $orders['-'.$modern_key] = array(
999 'vector' => array('-'.$modern_key, '-id'),
1000 'name' => pht('%s (Reversed)', $field->getFieldName()),
1001 );
1002 }
1003 }
1004
1005 if ($this->supportsFerretEngine()) {
1006 $orders['relevance'] = array(
1007 'vector' => array('rank', 'fulltext-modified', 'id'),
1008 'name' => pht('Relevance'),
1009 );
1010 }
1011
1012 return $orders;
1013 }
1014
1015 public function getBuiltinOrderAliasMap() {
1016 $orders = $this->getBuiltinOrders();
1017
1018 $map = array();
1019 foreach ($orders as $key => $order) {
1020 $keys = array();
1021 $keys[] = $key;
1022 foreach (idx($order, 'aliases', array()) as $alias) {
1023 $keys[] = $alias;
1024 }
1025
1026 foreach ($keys as $alias) {
1027 if (isset($map[$alias])) {
1028 throw new Exception(
1029 pht(
1030 'Two builtin orders ("%s" and "%s") define the same key or '.
1031 'alias ("%s"). Each order alias and key must be unique and '.
1032 'identify a single order.',
1033 $key,
1034 $map[$alias],
1035 $alias));
1036 }
1037 $map[$alias] = $key;
1038 }
1039 }
1040
1041 return $map;
1042 }
1043
1044
1045 /**
1046 * Set a low-level column ordering.
1047 *
1048 * This is a low-level method which offers granular control over column
1049 * ordering. In most cases, applications can more easily use
1050 * @{method:setOrder} to choose a high-level builtin order.
1051 *
1052 * To set an order vector, specify a list of order keys as provided by
1053 * @{method:getOrderableColumns}.
1054 *
1055 * @param PhabricatorQueryOrderVector|list<string> $vector List of order
1056 * keys.
1057 * @return $this
1058 * @task order
1059 */
1060 public function setOrderVector($vector) {
1061 $vector = PhabricatorQueryOrderVector::newFromVector($vector);
1062
1063 $orderable = $this->getOrderableColumns();
1064
1065 // Make sure that all the components identify valid columns.
1066 $unique = array();
1067 foreach ($vector as $order) {
1068 $key = $order->getOrderKey();
1069 if (empty($orderable[$key])) {
1070 $valid = implode(', ', array_keys($orderable));
1071 throw new Exception(
1072 pht(
1073 'This query ("%s") does not support sorting by order key "%s". '.
1074 'Supported orders are: %s.',
1075 get_class($this),
1076 $key,
1077 $valid));
1078 }
1079
1080 $unique[$key] = idx($orderable[$key], 'unique', false);
1081 }
1082
1083 // Make sure that the last column is unique so that this is a strong
1084 // ordering which can be used for paging.
1085 $last = last($unique);
1086 if ($last !== true) {
1087 throw new Exception(
1088 pht(
1089 'Order vector "%s" is invalid: the last column in an order must '.
1090 'be a column with unique values, but "%s" is not unique.',
1091 $vector->getAsString(),
1092 last_key($unique)));
1093 }
1094
1095 // Make sure that other columns are not unique; an ordering like "id, name"
1096 // does not make sense because only "id" can ever have an effect.
1097 array_pop($unique);
1098 foreach ($unique as $key => $is_unique) {
1099 if ($is_unique) {
1100 throw new Exception(
1101 pht(
1102 'Order vector "%s" is invalid: only the last column in an order '.
1103 'may be unique, but "%s" is a unique column and not the last '.
1104 'column in the order.',
1105 $vector->getAsString(),
1106 $key));
1107 }
1108 }
1109
1110 $this->orderVector = $vector;
1111 return $this;
1112 }
1113
1114
1115 /**
1116 * Get the effective order vector.
1117 *
1118 * @return PhabricatorQueryOrderVector Effective vector.
1119 * @task order
1120 */
1121 protected function getOrderVector() {
1122 if (!$this->orderVector) {
1123 if ($this->builtinOrder !== null) {
1124 $builtin_order = idx($this->getBuiltinOrders(), $this->builtinOrder);
1125 $vector = $builtin_order['vector'];
1126 } else {
1127 $vector = $this->getDefaultOrderVector();
1128 }
1129
1130 if ($this->groupVector) {
1131 $group = PhabricatorQueryOrderVector::newFromVector($this->groupVector);
1132 $group->appendVector($vector);
1133 $vector = $group;
1134 }
1135
1136 $vector = PhabricatorQueryOrderVector::newFromVector($vector);
1137
1138 // We call setOrderVector() here to apply checks to the default vector.
1139 // This catches any errors in the implementation.
1140 $this->setOrderVector($vector);
1141 }
1142
1143 return $this->orderVector;
1144 }
1145
1146
1147 /**
1148 * @task order
1149 */
1150 protected function getDefaultOrderVector() {
1151 return array('id');
1152 }
1153
1154
1155 /**
1156 * @return array<string,array<string,string>> PhutilKeyValueCacheStack string
1157 * (e.g. 'id', 'rank', 'fulltext-created', 'fulltext-modified') and the
1158 * cache value as an array, for example '{"table":"user","column":"id",
1159 * "reverse":false,"type":"int","unique":true}' or '{"table":null,
1160 * "column":"_ft_rank","type":"int","requires-ferret":true,"having":true}'
1161 * @task order
1162 */
1163 public function getOrderableColumns() {
1164 $cache = PhabricatorCaches::getRequestCache();
1165 $class = get_class($this);
1166 $cache_key = 'query.orderablecolumns.'.$class;
1167
1168 $columns = $cache->getKey($cache_key);
1169 if ($columns !== null) {
1170 return $columns;
1171 }
1172
1173 $columns = array(
1174 'id' => array(
1175 'table' => $this->getPrimaryTableAlias(),
1176 'column' => 'id',
1177 'reverse' => false,
1178 'type' => 'int',
1179 'unique' => true,
1180 ),
1181 );
1182
1183 $object = $this->newResultObject();
1184 if ($object instanceof PhabricatorCustomFieldInterface) {
1185 $list = PhabricatorCustomField::getObjectFields(
1186 $object,
1187 PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
1188 foreach ($list->getFields() as $field) {
1189 $index = $field->buildOrderIndex();
1190 if (!$index) {
1191 continue;
1192 }
1193
1194 $digest = $field->getFieldIndex();
1195
1196 $key = $field->getModernFieldKey();
1197
1198 $columns[$key] = array(
1199 'table' => 'appsearch_order_'.$digest,
1200 'column' => 'indexValue',
1201 'type' => $index->getIndexValueType(),
1202 'null' => 'tail',
1203 'customfield' => true,
1204 'customfield.index.table' => $index->getTableName(),
1205 'customfield.index.key' => $digest,
1206 );
1207 }
1208 }
1209
1210 if ($this->supportsFerretEngine()) {
1211 $columns['rank'] = array(
1212 'table' => null,
1213 'column' => self::FULLTEXT_RANK,
1214 'type' => 'int',
1215 'requires-ferret' => true,
1216 'having' => true,
1217 );
1218 $columns['fulltext-created'] = array(
1219 'table' => null,
1220 'column' => self::FULLTEXT_CREATED,
1221 'type' => 'int',
1222 'requires-ferret' => true,
1223 );
1224 $columns['fulltext-modified'] = array(
1225 'table' => null,
1226 'column' => self::FULLTEXT_MODIFIED,
1227 'type' => 'int',
1228 'requires-ferret' => true,
1229 );
1230 }
1231
1232 $cache->setKey($cache_key, $columns);
1233
1234 return $columns;
1235 }
1236
1237
1238 /**
1239 * @task order
1240 */
1241 final protected function buildOrderClause(
1242 AphrontDatabaseConnection $conn,
1243 $for_union = false) {
1244
1245 $orderable = $this->getOrderableColumns();
1246 $vector = $this->getQueryableOrderVector();
1247
1248 $parts = array();
1249 foreach ($vector as $order) {
1250 $part = $orderable[$order->getOrderKey()];
1251
1252 if ($order->getIsReversed()) {
1253 $part['reverse'] = !idx($part, 'reverse', false);
1254 }
1255 $parts[] = $part;
1256 }
1257
1258 return $this->formatOrderClause($conn, $parts, $for_union);
1259 }
1260
1261 /**
1262 * @return PhabricatorQueryOrderVector
1263 * @task order
1264 */
1265 private function getQueryableOrderVector() {
1266 $vector = $this->getOrderVector();
1267 $orderable = $this->getOrderableColumns();
1268
1269 $keep = array();
1270 foreach ($vector as $order) {
1271 $column = $orderable[$order->getOrderKey()];
1272
1273 // If this is a Ferret fulltext column but the query doesn't actually
1274 // have a fulltext query, we'll skip most of the Ferret stuff and won't
1275 // actually have the columns in the result set. Just skip them.
1276 if (!empty($column['requires-ferret'])) {
1277 if (!$this->getFerretTokens()) {
1278 continue;
1279 }
1280 }
1281
1282 $keep[] = $order->getAsScalar();
1283 }
1284
1285 return PhabricatorQueryOrderVector::newFromVector($keep);
1286 }
1287
1288 /**
1289 * @task order
1290 */
1291 protected function formatOrderClause(
1292 AphrontDatabaseConnection $conn,
1293 array $parts,
1294 $for_union = false) {
1295
1296 $is_query_reversed = $this->getIsQueryOrderReversed();
1297
1298 $sql = array();
1299 foreach ($parts as $key => $part) {
1300 $is_column_reversed = !empty($part['reverse']);
1301
1302 $descending = true;
1303 if ($is_query_reversed) {
1304 $descending = !$descending;
1305 }
1306
1307 if ($is_column_reversed) {
1308 $descending = !$descending;
1309 }
1310
1311 $table = idx($part, 'table');
1312
1313 // When we're building an ORDER BY clause for a sequence of UNION
1314 // statements, we can't refer to tables from the subqueries.
1315 if ($for_union) {
1316 $table = null;
1317 }
1318
1319 $column = $part['column'];
1320
1321 if ($table !== null) {
1322 $field = qsprintf($conn, '%T.%T', $table, $column);
1323 } else {
1324 $field = qsprintf($conn, '%T', $column);
1325 }
1326
1327 $null = idx($part, 'null');
1328 if ($null) {
1329 switch ($null) {
1330 case 'head':
1331 $null_field = qsprintf($conn, '(%Q IS NULL)', $field);
1332 break;
1333 case 'tail':
1334 $null_field = qsprintf($conn, '(%Q IS NOT NULL)', $field);
1335 break;
1336 default:
1337 throw new Exception(
1338 pht(
1339 'NULL value "%s" is invalid. Valid values are "head" and '.
1340 '"tail".',
1341 $null));
1342 }
1343
1344 if ($descending) {
1345 $sql[] = qsprintf($conn, '%Q DESC', $null_field);
1346 } else {
1347 $sql[] = qsprintf($conn, '%Q ASC', $null_field);
1348 }
1349 }
1350
1351 if ($descending) {
1352 $sql[] = qsprintf($conn, '%Q DESC', $field);
1353 } else {
1354 $sql[] = qsprintf($conn, '%Q ASC', $field);
1355 }
1356 }
1357
1358 return qsprintf($conn, 'ORDER BY %LQ', $sql);
1359 }
1360
1361
1362/* -( Application Search )------------------------------------------------- */
1363
1364
1365 /**
1366 * Constrain the query with an ApplicationSearch index, requiring field values
1367 * contain at least one of the values in a set.
1368 *
1369 * This constraint can build the most common types of queries, like:
1370 *
1371 * - Find users with shirt sizes "X" or "XL".
1372 * - Find shoes with size "13".
1373 *
1374 * @param PhabricatorCustomFieldIndexStorage $index Table where the index is
1375 * stored.
1376 * @param string|list<string> $value One or more values to filter by.
1377 * @return $this
1378 * @task appsearch
1379 */
1380 public function withApplicationSearchContainsConstraint(
1381 PhabricatorCustomFieldIndexStorage $index,
1382 $value) {
1383
1384 $values = (array)$value;
1385
1386 $data_values = array();
1387 $constraint_values = array();
1388 foreach ($values as $value) {
1389 if ($value instanceof PhabricatorQueryConstraint) {
1390 $constraint_values[] = $value;
1391 } else {
1392 $data_values[] = $value;
1393 }
1394 }
1395
1396 $alias = 'appsearch_'.count($this->applicationSearchConstraints);
1397
1398 $this->applicationSearchConstraints[] = array(
1399 'type' => $index->getIndexValueType(),
1400 'cond' => '=',
1401 'table' => $index->getTableName(),
1402 'index' => $index->getIndexKey(),
1403 'alias' => $alias,
1404 'value' => $values,
1405 'data' => $data_values,
1406 'constraints' => $constraint_values,
1407 );
1408
1409 return $this;
1410 }
1411
1412
1413 /**
1414 * Constrain the query with an ApplicationSearch index, requiring values
1415 * exist in a given range.
1416 *
1417 * This constraint is useful for expressing date ranges:
1418 *
1419 * - Find events between July 1st and July 7th.
1420 *
1421 * The ends of the range are inclusive, so a `$min` of `3` and a `$max` of
1422 * `5` will match fields with values `3`, `4`, or `5`. Providing `null` for
1423 * either end of the range will leave that end of the constraint open.
1424 *
1425 * @param PhabricatorCustomFieldIndexStorage $index Table where the index is
1426 * stored.
1427 * @param int|null $min Minimum permissible value, inclusive.
1428 * @param int|null $max Maximum permissible value, inclusive.
1429 * @return $this
1430 * @task appsearch
1431 */
1432 public function withApplicationSearchRangeConstraint(
1433 PhabricatorCustomFieldIndexStorage $index,
1434 $min,
1435 $max) {
1436
1437 $index_type = $index->getIndexValueType();
1438 if ($index_type != 'int') {
1439 throw new Exception(
1440 pht(
1441 'Attempting to apply a range constraint to a field with index type '.
1442 '"%s", expected type "%s".',
1443 $index_type,
1444 'int'));
1445 }
1446
1447 $alias = 'appsearch_'.count($this->applicationSearchConstraints);
1448
1449 $this->applicationSearchConstraints[] = array(
1450 'type' => $index->getIndexValueType(),
1451 'cond' => 'range',
1452 'table' => $index->getTableName(),
1453 'index' => $index->getIndexKey(),
1454 'alias' => $alias,
1455 'value' => array($min, $max),
1456 'data' => null,
1457 'constraints' => null,
1458 );
1459
1460 return $this;
1461 }
1462
1463
1464 /**
1465 * Get the name of the query's primary object PHID column, for constructing
1466 * JOIN clauses. Normally (and by default) this is just `"phid"`, but it may
1467 * be something more exotic.
1468 *
1469 * See @{method:getPrimaryTableAlias} if the column needs to be qualified with
1470 * a table alias.
1471 *
1472 * @param AphrontDatabaseConnection $conn Connection executing queries.
1473 * @return PhutilQueryString Column name.
1474 * @task appsearch
1475 */
1476 protected function getApplicationSearchObjectPHIDColumn(
1477 AphrontDatabaseConnection $conn) {
1478
1479 if ($this->getPrimaryTableAlias()) {
1480 return qsprintf($conn, '%T.phid', $this->getPrimaryTableAlias());
1481 } else {
1482 return qsprintf($conn, 'phid');
1483 }
1484 }
1485
1486
1487 /**
1488 * Determine if the JOINs built by ApplicationSearch might cause each primary
1489 * object to return multiple result rows. Generally, this means the query
1490 * needs an extra GROUP BY clause.
1491 *
1492 * @return bool True if the query may return multiple rows for each object.
1493 * @task appsearch
1494 */
1495 protected function getApplicationSearchMayJoinMultipleRows() {
1496 foreach ($this->applicationSearchConstraints as $constraint) {
1497 $type = $constraint['type'];
1498 $value = $constraint['value'];
1499 $cond = $constraint['cond'];
1500
1501 switch ($cond) {
1502 case '=':
1503 switch ($type) {
1504 case 'string':
1505 case 'int':
1506 if (count($value) > 1) {
1507 return true;
1508 }
1509 break;
1510 default:
1511 throw new Exception(pht('Unknown index type "%s"!', $type));
1512 }
1513 break;
1514 case 'range':
1515 // NOTE: It's possible to write a custom field where multiple rows
1516 // match a range constraint, but we don't currently ship any in the
1517 // upstream and I can't immediately come up with cases where this
1518 // would make sense.
1519 break;
1520 default:
1521 throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
1522 }
1523 }
1524
1525 return false;
1526 }
1527
1528
1529 /**
1530 * Construct a GROUP BY clause appropriate for ApplicationSearch constraints.
1531 *
1532 * @param AphrontDatabaseConnection $conn Connection executing the query.
1533 * @return string Group clause.
1534 * @task appsearch
1535 */
1536 protected function buildApplicationSearchGroupClause(
1537 AphrontDatabaseConnection $conn) {
1538
1539 if ($this->getApplicationSearchMayJoinMultipleRows()) {
1540 return qsprintf(
1541 $conn,
1542 'GROUP BY %Q',
1543 $this->getApplicationSearchObjectPHIDColumn($conn));
1544 } else {
1545 return qsprintf($conn, '');
1546 }
1547 }
1548
1549
1550 /**
1551 * Construct a JOIN clause appropriate for applying ApplicationSearch
1552 * constraints.
1553 *
1554 * @param AphrontDatabaseConnection $conn Connection executing the query.
1555 * @return string Join clause.
1556 * @task appsearch
1557 */
1558 protected function buildApplicationSearchJoinClause(
1559 AphrontDatabaseConnection $conn) {
1560
1561 $joins = array();
1562 foreach ($this->applicationSearchConstraints as $key => $constraint) {
1563 $table = $constraint['table'];
1564 $alias = $constraint['alias'];
1565 $index = $constraint['index'];
1566 $cond = $constraint['cond'];
1567 $phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
1568 switch ($cond) {
1569 case '=':
1570 // Figure out whether we need to do a LEFT JOIN or not. We need to
1571 // LEFT JOIN if we're going to select "IS NULL" rows.
1572 $join_type = qsprintf($conn, 'JOIN');
1573 foreach ($constraint['constraints'] as $query_constraint) {
1574 $op = $query_constraint->getOperator();
1575 if ($op === PhabricatorQueryConstraint::OPERATOR_NULL) {
1576 $join_type = qsprintf($conn, 'LEFT JOIN');
1577 break;
1578 }
1579 }
1580
1581 $joins[] = qsprintf(
1582 $conn,
1583 '%Q %T %T ON %T.objectPHID = %Q
1584 AND %T.indexKey = %s',
1585 $join_type,
1586 $table,
1587 $alias,
1588 $alias,
1589 $phid_column,
1590 $alias,
1591 $index);
1592 break;
1593 case 'range':
1594 list($min, $max) = $constraint['value'];
1595 if (($min === null) && ($max === null)) {
1596 // If there's no actual range constraint, just move on.
1597 break;
1598 }
1599
1600 if ($min === null) {
1601 $constraint_clause = qsprintf(
1602 $conn,
1603 '%T.indexValue <= %d',
1604 $alias,
1605 $max);
1606 } else if ($max === null) {
1607 $constraint_clause = qsprintf(
1608 $conn,
1609 '%T.indexValue >= %d',
1610 $alias,
1611 $min);
1612 } else {
1613 $constraint_clause = qsprintf(
1614 $conn,
1615 '%T.indexValue BETWEEN %d AND %d',
1616 $alias,
1617 $min,
1618 $max);
1619 }
1620
1621 $joins[] = qsprintf(
1622 $conn,
1623 'JOIN %T %T ON %T.objectPHID = %Q
1624 AND %T.indexKey = %s
1625 AND (%Q)',
1626 $table,
1627 $alias,
1628 $alias,
1629 $phid_column,
1630 $alias,
1631 $index,
1632 $constraint_clause);
1633 break;
1634 default:
1635 throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
1636 }
1637 }
1638
1639 $phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
1640 $orderable = $this->getOrderableColumns();
1641
1642 $vector = $this->getOrderVector();
1643 foreach ($vector as $order) {
1644 $spec = $orderable[$order->getOrderKey()];
1645 if (empty($spec['customfield'])) {
1646 continue;
1647 }
1648
1649 $table = $spec['customfield.index.table'];
1650 $alias = $spec['table'];
1651 $key = $spec['customfield.index.key'];
1652
1653 $joins[] = qsprintf(
1654 $conn,
1655 'LEFT JOIN %T %T ON %T.objectPHID = %Q
1656 AND %T.indexKey = %s',
1657 $table,
1658 $alias,
1659 $alias,
1660 $phid_column,
1661 $alias,
1662 $key);
1663 }
1664
1665 if ($joins) {
1666 return qsprintf($conn, '%LJ', $joins);
1667 } else {
1668 return qsprintf($conn, '');
1669 }
1670 }
1671
1672 /**
1673 * Construct a WHERE clause appropriate for applying ApplicationSearch
1674 * constraints.
1675 *
1676 * @param AphrontDatabaseConnection $conn Connection executing the query.
1677 * @return list<string> Where clause parts.
1678 * @task appsearch
1679 */
1680 protected function buildApplicationSearchWhereClause(
1681 AphrontDatabaseConnection $conn) {
1682
1683 $where = array();
1684
1685 foreach ($this->applicationSearchConstraints as $key => $constraint) {
1686 $alias = $constraint['alias'];
1687 $cond = $constraint['cond'];
1688 $type = $constraint['type'];
1689
1690 $data_values = $constraint['data'];
1691 $constraint_values = $constraint['constraints'];
1692
1693 $constraint_parts = array();
1694 switch ($cond) {
1695 case '=':
1696 if ($data_values) {
1697 switch ($type) {
1698 case 'string':
1699 $constraint_parts[] = qsprintf(
1700 $conn,
1701 '%T.indexValue IN (%Ls)',
1702 $alias,
1703 $data_values);
1704 break;
1705 case 'int':
1706 $constraint_parts[] = qsprintf(
1707 $conn,
1708 '%T.indexValue IN (%Ld)',
1709 $alias,
1710 $data_values);
1711 break;
1712 default:
1713 throw new Exception(pht('Unknown index type "%s"!', $type));
1714 }
1715 }
1716
1717 if ($constraint_values) {
1718 foreach ($constraint_values as $value) {
1719 $op = $value->getOperator();
1720 switch ($op) {
1721 case PhabricatorQueryConstraint::OPERATOR_NULL:
1722 $constraint_parts[] = qsprintf(
1723 $conn,
1724 '%T.indexValue IS NULL',
1725 $alias);
1726 break;
1727 case PhabricatorQueryConstraint::OPERATOR_ANY:
1728 $constraint_parts[] = qsprintf(
1729 $conn,
1730 '%T.indexValue IS NOT NULL',
1731 $alias);
1732 break;
1733 default:
1734 throw new Exception(
1735 pht(
1736 'No support for applying operator "%s" against '.
1737 'index of type "%s".',
1738 $op,
1739 $type));
1740 }
1741 }
1742 }
1743
1744 if ($constraint_parts) {
1745 $where[] = qsprintf($conn, '%LO', $constraint_parts);
1746 }
1747 break;
1748 }
1749 }
1750
1751 return $where;
1752 }
1753
1754
1755/* -( Integration with CustomField )--------------------------------------- */
1756
1757
1758 /**
1759 * @task customfield
1760 */
1761 protected function getPagingValueMapForCustomFields(
1762 PhabricatorCustomFieldInterface $object) {
1763
1764 // We have to get the current field values on the cursor object.
1765 $fields = PhabricatorCustomField::getObjectFields(
1766 $object,
1767 PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
1768 $fields->setViewer($this->getViewer());
1769 $fields->readFieldsFromStorage($object);
1770
1771 $map = array();
1772 foreach ($fields->getFields() as $field) {
1773 $map[$field->getModernFieldKey()] = $field->getValueForStorage();
1774 }
1775
1776 return $map;
1777 }
1778
1779
1780 /**
1781 * @task customfield
1782 */
1783 protected function isCustomFieldOrderKey($key) {
1784 $prefix = 'custom.';
1785 return !strncmp($key, $prefix, strlen($prefix));
1786 }
1787
1788
1789/* -( Ferret )------------------------------------------------------------- */
1790
1791
1792 public function supportsFerretEngine() {
1793 $object = $this->newResultObject();
1794 return ($object instanceof PhabricatorFerretInterface);
1795 }
1796
1797 public function withFerretQuery(
1798 PhabricatorFerretEngine $engine,
1799 PhabricatorSavedQuery $query) {
1800
1801 if (!$this->supportsFerretEngine()) {
1802 throw new Exception(
1803 pht(
1804 'Query ("%s") does not support the Ferret fulltext engine.',
1805 get_class($this)));
1806 }
1807
1808 $this->ferretEngine = $engine;
1809 $this->ferretQuery = $query;
1810
1811 return $this;
1812 }
1813
1814 public function getFerretTokens() {
1815 if (!$this->supportsFerretEngine()) {
1816 throw new Exception(
1817 pht(
1818 'Query ("%s") does not support the Ferret fulltext engine.',
1819 get_class($this)));
1820 }
1821
1822 return $this->ferretTokens;
1823 }
1824
1825 public function withFerretConstraint(
1826 PhabricatorFerretEngine $engine,
1827 array $fulltext_tokens) {
1828
1829 if (!$this->supportsFerretEngine()) {
1830 throw new Exception(
1831 pht(
1832 'Query ("%s") does not support the Ferret fulltext engine.',
1833 get_class($this)));
1834 }
1835
1836 if ($this->ferretEngine) {
1837 throw new Exception(
1838 pht(
1839 'Query may not have multiple fulltext constraints.'));
1840 }
1841
1842 if (!$fulltext_tokens) {
1843 return $this;
1844 }
1845
1846 $this->ferretEngine = $engine;
1847 $this->ferretTokens = $fulltext_tokens;
1848
1849 $op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;
1850
1851 $default_function = $engine->getDefaultFunctionKey();
1852 $table_map = array();
1853 $idx = 1;
1854 foreach ($this->ferretTokens as $fulltext_token) {
1855 $raw_token = $fulltext_token->getToken();
1856
1857 $function = $raw_token->getFunction();
1858 if ($function === null) {
1859 $function = $default_function;
1860 }
1861
1862 $function_def = $engine->getFunctionForName($function);
1863
1864 // NOTE: The query compiler guarantees that a query can not make a
1865 // field both "present" and "absent", so it's safe to just use the
1866 // first operator we encounter to determine whether the table is
1867 // optional or not.
1868
1869 $operator = $raw_token->getOperator();
1870 $is_optional = ($operator === $op_absent);
1871
1872 if (!isset($table_map[$function])) {
1873 $alias = 'ftfield_'.$idx++;
1874 $table_map[$function] = array(
1875 'alias' => $alias,
1876 'function' => $function_def,
1877 'optional' => $is_optional,
1878 );
1879 }
1880 }
1881
1882 // Join the title field separately so we can rank results.
1883 $table_map['rank'] = array(
1884 'alias' => 'ft_rank',
1885 'function' => $engine->getFunctionForName('title'),
1886
1887 // See T13345. Not every document has a title, so we want to LEFT JOIN
1888 // this table to avoid excluding documents with no title that match
1889 // the query in other fields.
1890 'optional' => true,
1891 );
1892
1893 $this->ferretTables = $table_map;
1894
1895 return $this;
1896 }
1897
1898 protected function buildFerretSelectClause(AphrontDatabaseConnection $conn) {
1899 $select = array();
1900
1901 if (!$this->supportsFerretEngine()) {
1902 return $select;
1903 }
1904
1905 if (!$this->hasFerretOrder()) {
1906 // We only need to SELECT the virtual rank/relevance columns if we're
1907 // actually sorting the results by rank.
1908 return $select;
1909 }
1910
1911 if (!$this->ferretEngine) {
1912 $select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_RANK);
1913 $select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_CREATED);
1914 $select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_MODIFIED);
1915 return $select;
1916 }
1917
1918 $engine = $this->ferretEngine;
1919 $stemmer = $engine->newStemmer();
1920
1921 $op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
1922 $op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
1923 $table_alias = 'ft_rank';
1924
1925 $parts = array();
1926 foreach ($this->ferretTokens as $fulltext_token) {
1927 $raw_token = $fulltext_token->getToken();
1928 $value = $raw_token->getValue();
1929
1930 if ($raw_token->getOperator() == $op_not) {
1931 // Ignore "not" terms when ranking, since they aren't useful.
1932 continue;
1933 }
1934
1935 if ($raw_token->getOperator() == $op_sub) {
1936 $is_substring = true;
1937 } else {
1938 $is_substring = false;
1939 }
1940
1941 if ($is_substring) {
1942 $parts[] = qsprintf(
1943 $conn,
1944 'IF(%T.rawCorpus LIKE %~, 2, 0)',
1945 $table_alias,
1946 $value);
1947 continue;
1948 }
1949
1950 if ($raw_token->isQuoted()) {
1951 $is_quoted = true;
1952 $is_stemmed = false;
1953 } else {
1954 $is_quoted = false;
1955 $is_stemmed = true;
1956 }
1957
1958 $term_constraints = array();
1959
1960 $term_value = $engine->newTermsCorpus($value);
1961
1962 $parts[] = qsprintf(
1963 $conn,
1964 'IF(%T.termCorpus LIKE %~, 2, 0)',
1965 $table_alias,
1966 $term_value);
1967
1968 if ($is_stemmed) {
1969 $stem_value = $stemmer->stemToken($value);
1970 $stem_value = $engine->newTermsCorpus($stem_value);
1971
1972 $parts[] = qsprintf(
1973 $conn,
1974 'IF(%T.normalCorpus LIKE %~, 1, 0)',
1975 $table_alias,
1976 $stem_value);
1977 }
1978 }
1979
1980 $parts[] = qsprintf($conn, '%d', 0);
1981
1982 $sum = array_shift($parts);
1983 foreach ($parts as $part) {
1984 $sum = qsprintf(
1985 $conn,
1986 '%Q + %Q',
1987 $sum,
1988 $part);
1989 }
1990
1991 $select[] = qsprintf(
1992 $conn,
1993 '%Q AS %T',
1994 $sum,
1995 self::FULLTEXT_RANK);
1996
1997 // See D20297. We select these as real columns in the result set so that
1998 // constructions like this will work:
1999 //
2000 // ((SELECT ...) UNION (SELECT ...)) ORDER BY ...
2001 //
2002 // If the columns aren't part of the result set, the final "ORDER BY" can
2003 // not act on them.
2004
2005 $select[] = qsprintf(
2006 $conn,
2007 'ft_doc.epochCreated AS %T',
2008 self::FULLTEXT_CREATED);
2009
2010 $select[] = qsprintf(
2011 $conn,
2012 'ft_doc.epochModified AS %T',
2013 self::FULLTEXT_MODIFIED);
2014
2015 return $select;
2016 }
2017
2018 protected function buildFerretJoinClause(AphrontDatabaseConnection $conn) {
2019 if (!$this->ferretEngine) {
2020 return array();
2021 }
2022
2023 $op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
2024 $op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
2025 $op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;
2026 $op_present = PhutilSearchQueryCompiler::OPERATOR_PRESENT;
2027
2028 $engine = $this->ferretEngine;
2029 $stemmer = $engine->newStemmer();
2030
2031 $ngram_table = $engine->getNgramsTableName();
2032 $ngram_engine = $this->getNgramEngine();
2033
2034 $flat = array();
2035 foreach ($this->ferretTokens as $fulltext_token) {
2036 $raw_token = $fulltext_token->getToken();
2037
2038 $operator = $raw_token->getOperator();
2039
2040 // If this is a negated term like "-pomegranate", don't join the ngram
2041 // table since we aren't looking for documents with this term. (We could
2042 // LEFT JOIN the table and require a NULL row, but this is probably more
2043 // trouble than it's worth.)
2044 if ($operator === $op_not) {
2045 continue;
2046 }
2047
2048 // Neither the "present" or "absent" operators benefit from joining
2049 // the ngram table.
2050 if ($operator === $op_absent || $operator === $op_present) {
2051 continue;
2052 }
2053
2054 $value = $raw_token->getValue();
2055
2056 $length = count(phutil_utf8v($value));
2057
2058 if ($raw_token->getOperator() == $op_sub) {
2059 $is_substring = true;
2060 } else {
2061 $is_substring = false;
2062 }
2063
2064 // If the user specified a substring query for a substring which is
2065 // shorter than the ngram length, we can't use the ngram index, so
2066 // don't do a join. We'll fall back to just doing LIKE on the full
2067 // corpus.
2068 if ($is_substring) {
2069 if ($length < 3) {
2070 continue;
2071 }
2072 }
2073
2074 if ($raw_token->isQuoted()) {
2075 $is_stemmed = false;
2076 } else {
2077 $is_stemmed = true;
2078 }
2079
2080 if ($is_substring) {
2081 $ngrams = $ngram_engine->getSubstringNgramsFromString($value);
2082 } else {
2083 $terms_value = $engine->newTermsCorpus($value);
2084 $ngrams = $ngram_engine->getTermNgramsFromString($terms_value);
2085
2086 // If this is a stemmed term, only look for ngrams present in both the
2087 // unstemmed and stemmed variations.
2088 if ($is_stemmed) {
2089 // Trim the boundary space characters so the stemmer recognizes this
2090 // is (or, at least, may be) a normal word and activates.
2091 $terms_value = trim($terms_value, ' ');
2092 $stem_value = $stemmer->stemToken($terms_value);
2093 $stem_ngrams = $ngram_engine->getTermNgramsFromString($stem_value);
2094 $ngrams = array_intersect($ngrams, $stem_ngrams);
2095 }
2096 }
2097
2098 foreach ($ngrams as $ngram) {
2099 $flat[] = array(
2100 'table' => $ngram_table,
2101 'ngram' => $ngram,
2102 );
2103 }
2104 }
2105
2106 // Remove common ngrams, like "the", which occur too frequently in
2107 // documents to be useful in constraining the query. The best ngrams
2108 // are obscure sequences which occur in very few documents.
2109
2110 if ($flat) {
2111 $common_ngrams = queryfx_all(
2112 $conn,
2113 'SELECT ngram FROM %T WHERE ngram IN (%Ls)',
2114 $engine->getCommonNgramsTableName(),
2115 ipull($flat, 'ngram'));
2116 $common_ngrams = ipull($common_ngrams, 'ngram', 'ngram');
2117
2118 foreach ($flat as $key => $spec) {
2119 $ngram = $spec['ngram'];
2120 if (isset($common_ngrams[$ngram])) {
2121 unset($flat[$key]);
2122 continue;
2123 }
2124
2125 // NOTE: MySQL discards trailing whitespace in CHAR(X) columns.
2126 $trim_ngram = rtrim($ngram, ' ');
2127 if (isset($common_ngrams[$trim_ngram])) {
2128 unset($flat[$key]);
2129 continue;
2130 }
2131 }
2132 }
2133
2134 // MySQL only allows us to join a maximum of 61 tables per query. Each
2135 // ngram is going to cost us a join toward that limit, so if the user
2136 // specified a very long query string, just pick 16 of the ngrams
2137 // at random.
2138 if (count($flat) > 16) {
2139 shuffle($flat);
2140 $flat = array_slice($flat, 0, 16);
2141 }
2142
2143 $alias = $this->getPrimaryTableAlias();
2144 if ($alias) {
2145 $phid_column = qsprintf($conn, '%T.%T', $alias, 'phid');
2146 } else {
2147 $phid_column = qsprintf($conn, '%T', 'phid');
2148 }
2149
2150 $document_table = $engine->getDocumentTableName();
2151 $field_table = $engine->getFieldTableName();
2152
2153 $joins = array();
2154 $joins[] = qsprintf(
2155 $conn,
2156 'JOIN %T ft_doc ON ft_doc.objectPHID = %Q',
2157 $document_table,
2158 $phid_column);
2159
2160 $idx = 1;
2161 foreach ($flat as $spec) {
2162 $table = $spec['table'];
2163 $ngram = $spec['ngram'];
2164
2165 $alias = 'ftngram_'.$idx++;
2166
2167 $joins[] = qsprintf(
2168 $conn,
2169 'JOIN %T %T ON %T.documentID = ft_doc.id AND %T.ngram = %s',
2170 $table,
2171 $alias,
2172 $alias,
2173 $alias,
2174 $ngram);
2175 }
2176
2177 $object = $this->newResultObject();
2178 if (!$object) {
2179 throw new Exception(
2180 pht(
2181 'Query class ("%s") must define "newResultObject()" to use '.
2182 'Ferret constraints.',
2183 get_class($this)));
2184 }
2185
2186 // See T13511. If we have a fulltext query which uses valid field
2187 // functions, but at least one of the functions applies to a field which
2188 // the object can never have, the query can never match anything. Detect
2189 // this and return an empty result set.
2190
2191 // (Even if the query is "field is absent" or "field does not contain
2192 // such-and-such", the interpretation is that these constraints are
2193 // not meaningful when applied to an object which can never have the
2194 // field.)
2195
2196 $functions = ipull($this->ferretTables, 'function');
2197 $functions = mpull($functions, null, 'getFerretFunctionName');
2198 foreach ($functions as $function) {
2199 if (!$function->supportsObject($object)) {
2200 throw new PhabricatorEmptyQueryException(
2201 pht(
2202 'This query uses a fulltext function which this document '.
2203 'type does not support.'));
2204 }
2205 }
2206
2207 foreach ($this->ferretTables as $table) {
2208 $alias = $table['alias'];
2209
2210 if (empty($table['optional'])) {
2211 $join_type = qsprintf($conn, 'JOIN');
2212 } else {
2213 $join_type = qsprintf($conn, 'LEFT JOIN');
2214 }
2215
2216 $joins[] = qsprintf(
2217 $conn,
2218 '%Q %T %T ON ft_doc.id = %T.documentID
2219 AND %T.fieldKey = %s',
2220 $join_type,
2221 $field_table,
2222 $alias,
2223 $alias,
2224 $alias,
2225 $table['function']->getFerretFieldKey());
2226 }
2227
2228 return $joins;
2229 }
2230
2231 protected function buildFerretWhereClause(AphrontDatabaseConnection $conn) {
2232 if (!$this->ferretEngine) {
2233 return array();
2234 }
2235
2236 $engine = $this->ferretEngine;
2237 $stemmer = $engine->newStemmer();
2238 $table_map = $this->ferretTables;
2239
2240 $op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
2241 $op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
2242 $op_exact = PhutilSearchQueryCompiler::OPERATOR_EXACT;
2243 $op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;
2244 $op_present = PhutilSearchQueryCompiler::OPERATOR_PRESENT;
2245
2246 $where = array();
2247 $default_function = $engine->getDefaultFunctionKey();
2248 foreach ($this->ferretTokens as $fulltext_token) {
2249 $raw_token = $fulltext_token->getToken();
2250 $value = $raw_token->getValue();
2251
2252 $function = $raw_token->getFunction();
2253 if ($function === null) {
2254 $function = $default_function;
2255 }
2256
2257 $operator = $raw_token->getOperator();
2258
2259 $table_alias = $table_map[$function]['alias'];
2260
2261 // If this is a "field is present" operator, we've already implicitly
2262 // guaranteed this by JOINing the table. We don't need to do any
2263 // more work.
2264 $is_present = ($operator === $op_present);
2265 if ($is_present) {
2266 continue;
2267 }
2268
2269 // If this is a "field is absent" operator, we just want documents
2270 // which failed to match to a row when we LEFT JOINed the table. This
2271 // means there's no index for the field.
2272 $is_absent = ($operator === $op_absent);
2273 if ($is_absent) {
2274 $where[] = qsprintf(
2275 $conn,
2276 '(%T.rawCorpus IS NULL)',
2277 $table_alias);
2278 continue;
2279 }
2280
2281 $is_not = ($operator === $op_not);
2282
2283 if ($operator == $op_sub) {
2284 $is_substring = true;
2285 } else {
2286 $is_substring = false;
2287 }
2288
2289 // If we're doing exact search, just test the raw corpus.
2290 $is_exact = ($operator === $op_exact);
2291 if ($is_exact) {
2292 if ($is_not) {
2293 $where[] = qsprintf(
2294 $conn,
2295 '(%T.rawCorpus != %s)',
2296 $table_alias,
2297 $value);
2298 } else {
2299 $where[] = qsprintf(
2300 $conn,
2301 '(%T.rawCorpus = %s)',
2302 $table_alias,
2303 $value);
2304 }
2305 continue;
2306 }
2307
2308 // If we're doing substring search, we just match against the raw corpus
2309 // and we're done.
2310 if ($is_substring) {
2311 if ($is_not) {
2312 $where[] = qsprintf(
2313 $conn,
2314 '(%T.rawCorpus NOT LIKE %~)',
2315 $table_alias,
2316 $value);
2317 } else {
2318 $where[] = qsprintf(
2319 $conn,
2320 '(%T.rawCorpus LIKE %~)',
2321 $table_alias,
2322 $value);
2323 }
2324 continue;
2325 }
2326
2327 // Otherwise, we need to match against the term corpus and the normal
2328 // corpus, so that searching for "raw" does not find "strawberry".
2329 if ($raw_token->isQuoted()) {
2330 $is_quoted = true;
2331 $is_stemmed = false;
2332 } else {
2333 $is_quoted = false;
2334 $is_stemmed = true;
2335 }
2336
2337 // Never stem negated queries, since this can exclude results users
2338 // did not mean to exclude and generally confuse things.
2339 if ($is_not) {
2340 $is_stemmed = false;
2341 }
2342
2343 $term_constraints = array();
2344
2345 $term_value = $engine->newTermsCorpus($value);
2346 if ($is_not) {
2347 $term_constraints[] = qsprintf(
2348 $conn,
2349 '(%T.termCorpus NOT LIKE %~)',
2350 $table_alias,
2351 $term_value);
2352 } else {
2353 $term_constraints[] = qsprintf(
2354 $conn,
2355 '(%T.termCorpus LIKE %~)',
2356 $table_alias,
2357 $term_value);
2358 }
2359
2360 if ($is_stemmed) {
2361 $stem_value = $stemmer->stemToken($value);
2362 $stem_value = $engine->newTermsCorpus($stem_value);
2363
2364 $term_constraints[] = qsprintf(
2365 $conn,
2366 '(%T.normalCorpus LIKE %~)',
2367 $table_alias,
2368 $stem_value);
2369 }
2370
2371 if ($is_not) {
2372 $where[] = qsprintf(
2373 $conn,
2374 '%LA',
2375 $term_constraints);
2376 } else if ($is_quoted) {
2377 $where[] = qsprintf(
2378 $conn,
2379 '(%T.rawCorpus LIKE %~ AND %LO)',
2380 $table_alias,
2381 $value,
2382 $term_constraints);
2383 } else {
2384 $where[] = qsprintf(
2385 $conn,
2386 '%LO',
2387 $term_constraints);
2388 }
2389 }
2390
2391 if ($this->ferretQuery) {
2392 $query = $this->ferretQuery;
2393
2394 $author_phids = $query->getParameter('authorPHIDs');
2395 if ($author_phids) {
2396 $where[] = qsprintf(
2397 $conn,
2398 'ft_doc.authorPHID IN (%Ls)',
2399 $author_phids);
2400 }
2401
2402 $with_unowned = $query->getParameter('withUnowned');
2403 $with_any = $query->getParameter('withAnyOwner');
2404
2405 if ($with_any && $with_unowned) {
2406 throw new PhabricatorEmptyQueryException(
2407 pht(
2408 'This query matches only unowned documents owned by anyone, '.
2409 'which is impossible.'));
2410 }
2411
2412 $owner_phids = $query->getParameter('ownerPHIDs');
2413 if ($owner_phids && !$with_any) {
2414 if ($with_unowned) {
2415 $where[] = qsprintf(
2416 $conn,
2417 'ft_doc.ownerPHID IN (%Ls) OR ft_doc.ownerPHID IS NULL',
2418 $owner_phids);
2419 } else {
2420 $where[] = qsprintf(
2421 $conn,
2422 'ft_doc.ownerPHID IN (%Ls)',
2423 $owner_phids);
2424 }
2425 } else if ($with_unowned) {
2426 $where[] = qsprintf(
2427 $conn,
2428 'ft_doc.ownerPHID IS NULL');
2429 }
2430
2431 if ($with_any) {
2432 $where[] = qsprintf(
2433 $conn,
2434 'ft_doc.ownerPHID IS NOT NULL');
2435 }
2436
2437 $rel_open = PhabricatorSearchRelationship::RELATIONSHIP_OPEN;
2438
2439 $statuses = $query->getParameter('statuses');
2440 $is_closed = null;
2441 if ($statuses) {
2442 $statuses = array_fuse($statuses);
2443 if (count($statuses) == 1) {
2444 if (isset($statuses[$rel_open])) {
2445 $is_closed = 0;
2446 } else {
2447 $is_closed = 1;
2448 }
2449 }
2450 }
2451
2452 if ($is_closed !== null) {
2453 $where[] = qsprintf(
2454 $conn,
2455 'ft_doc.isClosed = %d',
2456 $is_closed);
2457 }
2458 }
2459
2460 return $where;
2461 }
2462
2463 protected function shouldGroupFerretResultRows() {
2464 return (bool)$this->ferretTokens;
2465 }
2466
2467
2468/* -( Ngrams )------------------------------------------------------------- */
2469
2470
2471 protected function withNgramsConstraint(
2472 PhabricatorSearchNgrams $index,
2473 $value) {
2474
2475 if (phutil_nonempty_string($value)) {
2476 $this->ngrams[] = array(
2477 'index' => $index,
2478 'value' => $value,
2479 'length' => count(phutil_utf8v($value)),
2480 );
2481 }
2482
2483 return $this;
2484 }
2485
2486
2487 protected function buildNgramsJoinClause(AphrontDatabaseConnection $conn) {
2488 $ngram_engine = $this->getNgramEngine();
2489
2490 $flat = array();
2491 foreach ($this->ngrams as $spec) {
2492 $length = $spec['length'];
2493
2494 if ($length < 3) {
2495 continue;
2496 }
2497
2498 $index = $spec['index'];
2499 $value = $spec['value'];
2500
2501 $ngrams = $ngram_engine->getSubstringNgramsFromString($value);
2502
2503 foreach ($ngrams as $ngram) {
2504 $flat[] = array(
2505 'table' => $index->getTableName(),
2506 'ngram' => $ngram,
2507 );
2508 }
2509 }
2510
2511 if (!$flat) {
2512 return array();
2513 }
2514
2515 // MySQL only allows us to join a maximum of 61 tables per query. Each
2516 // ngram is going to cost us a join toward that limit, so if the user
2517 // specified a very long query string, just pick 16 of the ngrams
2518 // at random.
2519 if (count($flat) > 16) {
2520 shuffle($flat);
2521 $flat = array_slice($flat, 0, 16);
2522 }
2523
2524 $alias = $this->getPrimaryTableAlias();
2525 if ($alias) {
2526 $id_column = qsprintf($conn, '%T.%T', $alias, 'id');
2527 } else {
2528 $id_column = qsprintf($conn, '%T', 'id');
2529 }
2530
2531 $idx = 1;
2532 $joins = array();
2533 foreach ($flat as $spec) {
2534 $table = $spec['table'];
2535 $ngram = $spec['ngram'];
2536
2537 $alias = 'ngm'.$idx++;
2538
2539 $joins[] = qsprintf(
2540 $conn,
2541 'JOIN %T %T ON %T.objectID = %Q AND %T.ngram = %s',
2542 $table,
2543 $alias,
2544 $alias,
2545 $id_column,
2546 $alias,
2547 $ngram);
2548 }
2549
2550 return $joins;
2551 }
2552
2553
2554 protected function buildNgramsWhereClause(AphrontDatabaseConnection $conn) {
2555 $where = array();
2556
2557 $ngram_engine = $this->getNgramEngine();
2558
2559 foreach ($this->ngrams as $ngram) {
2560 $index = $ngram['index'];
2561 $value = $ngram['value'];
2562
2563 $column = $index->getColumnName();
2564 $alias = $this->getPrimaryTableAlias();
2565 if ($alias) {
2566 $column = qsprintf($conn, '%T.%T', $alias, $column);
2567 } else {
2568 $column = qsprintf($conn, '%T', $column);
2569 }
2570
2571 $tokens = $ngram_engine->tokenizeNgramString($value);
2572
2573 foreach ($tokens as $token) {
2574 $where[] = qsprintf(
2575 $conn,
2576 '%Q LIKE %~',
2577 $column,
2578 $token);
2579 }
2580 }
2581
2582 return $where;
2583 }
2584
2585
2586 protected function shouldGroupNgramResultRows() {
2587 return (bool)$this->ngrams;
2588 }
2589
2590 private function getNgramEngine() {
2591 if (!$this->ngramEngine) {
2592 $this->ngramEngine = new PhabricatorSearchNgramEngine();
2593 }
2594
2595 return $this->ngramEngine;
2596 }
2597
2598
2599/* -( Edge Logic )--------------------------------------------------------- */
2600
2601
2602 /**
2603 * Convenience method for specifying edge logic constraints with a list of
2604 * PHIDs.
2605 *
2606 * @param int $edge_type Edge type constant (SomeClassEdgeType::EDGECONST).
2607 * @param string $operator Constraint operator.
2608 * @param list<string> $phids List of PHIDs.
2609 * @return $this
2610 * @task edgelogic
2611 */
2612 public function withEdgeLogicPHIDs($edge_type, $operator, array $phids) {
2613 $constraints = array();
2614 foreach ($phids as $phid) {
2615 $constraints[] = new PhabricatorQueryConstraint($operator, $phid);
2616 }
2617
2618 return $this->withEdgeLogicConstraints($edge_type, $constraints);
2619 }
2620
2621
2622 /**
2623 * @param int $edge_type An edge type's EDGECONST constant
2624 * @param array<PhabricatorQueryConstraint> $constraints
2625 * @return $this
2626 * @task edgelogic
2627 */
2628 public function withEdgeLogicConstraints($edge_type, array $constraints) {
2629 assert_instances_of($constraints, PhabricatorQueryConstraint::class);
2630
2631 $constraints = mgroup($constraints, 'getOperator');
2632 foreach ($constraints as $operator => $list) {
2633 foreach ($list as $item) {
2634 $this->edgeLogicConstraints[$edge_type][$operator][] = $item;
2635 }
2636 }
2637
2638 $this->edgeLogicConstraintsAreValid = false;
2639
2640 return $this;
2641 }
2642
2643
2644 /**
2645 * @task edgelogic
2646 */
2647 public function buildEdgeLogicSelectClause(AphrontDatabaseConnection $conn) {
2648 $select = array();
2649
2650 $this->validateEdgeLogicConstraints();
2651
2652 foreach ($this->edgeLogicConstraints as $type => $constraints) {
2653 foreach ($constraints as $operator => $list) {
2654 $alias = $this->getEdgeLogicTableAlias($operator, $type);
2655 switch ($operator) {
2656 case PhabricatorQueryConstraint::OPERATOR_AND:
2657 if (count($list) > 1) {
2658 $select[] = qsprintf(
2659 $conn,
2660 'COUNT(DISTINCT(%T.dst)) %T',
2661 $alias,
2662 $this->buildEdgeLogicTableAliasCount($alias));
2663 }
2664 break;
2665 case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
2666 // This is tricky. We have a query which specifies multiple
2667 // projects, each of which may have an arbitrarily large number
2668 // of descendants.
2669
2670 // Suppose the projects are "Engineering" and "Operations", and
2671 // "Engineering" has subprojects X, Y and Z.
2672
2673 // We first use `FIELD(dst, X, Y, Z)` to produce a 0 if a row
2674 // is not part of Engineering at all, or some number other than
2675 // 0 if it is.
2676
2677 // Then we use `IF(..., idx, NULL)` to convert the 0 to a NULL and
2678 // any other value to an index (say, 1) for the ancestor.
2679
2680 // We build these up for every ancestor, then use `COALESCE(...)`
2681 // to select the non-null one, giving us an ancestor which this
2682 // row is a member of.
2683
2684 // From there, we use `COUNT(DISTINCT(...))` to make sure that
2685 // each result row is a member of all ancestors.
2686 if (count($list) > 1) {
2687 $idx = 1;
2688 $parts = array();
2689 foreach ($list as $constraint) {
2690 $parts[] = qsprintf(
2691 $conn,
2692 'IF(FIELD(%T.dst, %Ls) != 0, %d, NULL)',
2693 $alias,
2694 (array)$constraint->getValue(),
2695 $idx++);
2696 }
2697 $parts = qsprintf($conn, '%LQ', $parts);
2698
2699 $select[] = qsprintf(
2700 $conn,
2701 'COUNT(DISTINCT(COALESCE(%Q))) %T',
2702 $parts,
2703 $this->buildEdgeLogicTableAliasAncestor($alias));
2704 }
2705 break;
2706 default:
2707 break;
2708 }
2709 }
2710 }
2711
2712 return $select;
2713 }
2714
2715
2716 /**
2717 * @task edgelogic
2718 */
2719 public function buildEdgeLogicJoinClause(AphrontDatabaseConnection $conn) {
2720 $edge_table = PhabricatorEdgeConfig::TABLE_NAME_EDGE;
2721 $phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
2722
2723 $joins = array();
2724 foreach ($this->edgeLogicConstraints as $type => $constraints) {
2725
2726 $op_null = PhabricatorQueryConstraint::OPERATOR_NULL;
2727 $has_null = isset($constraints[$op_null]);
2728
2729 // If we're going to process an only() operator, build a list of the
2730 // acceptable set of PHIDs first. We'll only match results which have
2731 // no edges to any other PHIDs.
2732 $all_phids = array();
2733 if (isset($constraints[PhabricatorQueryConstraint::OPERATOR_ONLY])) {
2734 foreach ($constraints as $operator => $list) {
2735 switch ($operator) {
2736 case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
2737 case PhabricatorQueryConstraint::OPERATOR_AND:
2738 case PhabricatorQueryConstraint::OPERATOR_OR:
2739 foreach ($list as $constraint) {
2740 $value = (array)$constraint->getValue();
2741 foreach ($value as $v) {
2742 $all_phids[$v] = $v;
2743 }
2744 }
2745 break;
2746 }
2747 }
2748 }
2749
2750 foreach ($constraints as $operator => $list) {
2751 $alias = $this->getEdgeLogicTableAlias($operator, $type);
2752
2753 $phids = array();
2754 foreach ($list as $constraint) {
2755 $value = (array)$constraint->getValue();
2756 foreach ($value as $v) {
2757 $phids[$v] = $v;
2758 }
2759 }
2760 $phids = array_keys($phids);
2761
2762 switch ($operator) {
2763 case PhabricatorQueryConstraint::OPERATOR_NOT:
2764 $joins[] = qsprintf(
2765 $conn,
2766 'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d
2767 AND %T.dst IN (%Ls)',
2768 $edge_table,
2769 $alias,
2770 $phid_column,
2771 $alias,
2772 $alias,
2773 $type,
2774 $alias,
2775 $phids);
2776 break;
2777 case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
2778 case PhabricatorQueryConstraint::OPERATOR_AND:
2779 case PhabricatorQueryConstraint::OPERATOR_OR:
2780 // If we're including results with no matches, we have to degrade
2781 // this to a LEFT join. We'll use WHERE to select matching rows
2782 // later.
2783 if ($has_null) {
2784 $join_type = qsprintf($conn, 'LEFT');
2785 } else {
2786 $join_type = qsprintf($conn, '');
2787 }
2788
2789 $joins[] = qsprintf(
2790 $conn,
2791 '%Q JOIN %T %T ON %Q = %T.src AND %T.type = %d
2792 AND %T.dst IN (%Ls)',
2793 $join_type,
2794 $edge_table,
2795 $alias,
2796 $phid_column,
2797 $alias,
2798 $alias,
2799 $type,
2800 $alias,
2801 $phids);
2802 break;
2803 case PhabricatorQueryConstraint::OPERATOR_NULL:
2804 $joins[] = qsprintf(
2805 $conn,
2806 'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d',
2807 $edge_table,
2808 $alias,
2809 $phid_column,
2810 $alias,
2811 $alias,
2812 $type);
2813 break;
2814 case PhabricatorQueryConstraint::OPERATOR_ONLY:
2815 $joins[] = qsprintf(
2816 $conn,
2817 'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d
2818 AND %T.dst NOT IN (%Ls)',
2819 $edge_table,
2820 $alias,
2821 $phid_column,
2822 $alias,
2823 $alias,
2824 $type,
2825 $alias,
2826 $all_phids);
2827 break;
2828 }
2829 }
2830 }
2831
2832 return $joins;
2833 }
2834
2835
2836 /**
2837 * @task edgelogic
2838 */
2839 public function buildEdgeLogicWhereClause(AphrontDatabaseConnection $conn) {
2840 $where = array();
2841
2842 foreach ($this->edgeLogicConstraints as $type => $constraints) {
2843
2844 $full = array();
2845 $null = array();
2846
2847 $op_null = PhabricatorQueryConstraint::OPERATOR_NULL;
2848 $has_null = isset($constraints[$op_null]);
2849
2850 foreach ($constraints as $operator => $list) {
2851 $alias = $this->getEdgeLogicTableAlias($operator, $type);
2852 switch ($operator) {
2853 case PhabricatorQueryConstraint::OPERATOR_NOT:
2854 case PhabricatorQueryConstraint::OPERATOR_ONLY:
2855 $full[] = qsprintf(
2856 $conn,
2857 '%T.dst IS NULL',
2858 $alias);
2859 break;
2860 case PhabricatorQueryConstraint::OPERATOR_AND:
2861 case PhabricatorQueryConstraint::OPERATOR_OR:
2862 if ($has_null) {
2863 $full[] = qsprintf(
2864 $conn,
2865 '%T.dst IS NOT NULL',
2866 $alias);
2867 }
2868 break;
2869 case PhabricatorQueryConstraint::OPERATOR_NULL:
2870 $null[] = qsprintf(
2871 $conn,
2872 '%T.dst IS NULL',
2873 $alias);
2874 break;
2875 }
2876 }
2877
2878 if ($full && $null) {
2879 $where[] = qsprintf($conn, '(%LA OR %LA)', $full, $null);
2880 } else if ($full) {
2881 foreach ($full as $condition) {
2882 $where[] = $condition;
2883 }
2884 } else if ($null) {
2885 foreach ($null as $condition) {
2886 $where[] = $condition;
2887 }
2888 }
2889 }
2890
2891 return $where;
2892 }
2893
2894
2895 /**
2896 * @task edgelogic
2897 */
2898 public function buildEdgeLogicHavingClause(AphrontDatabaseConnection $conn) {
2899 $having = array();
2900
2901 foreach ($this->edgeLogicConstraints as $type => $constraints) {
2902 foreach ($constraints as $operator => $list) {
2903 $alias = $this->getEdgeLogicTableAlias($operator, $type);
2904 switch ($operator) {
2905 case PhabricatorQueryConstraint::OPERATOR_AND:
2906 if (count($list) > 1) {
2907 $having[] = qsprintf(
2908 $conn,
2909 '%T = %d',
2910 $this->buildEdgeLogicTableAliasCount($alias),
2911 count($list));
2912 }
2913 break;
2914 case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
2915 if (count($list) > 1) {
2916 $having[] = qsprintf(
2917 $conn,
2918 '%T = %d',
2919 $this->buildEdgeLogicTableAliasAncestor($alias),
2920 count($list));
2921 }
2922 break;
2923 }
2924 }
2925 }
2926
2927 return $having;
2928 }
2929
2930
2931 /**
2932 * @task edgelogic
2933 */
2934 public function shouldGroupEdgeLogicResultRows() {
2935 foreach ($this->edgeLogicConstraints as $type => $constraints) {
2936 foreach ($constraints as $operator => $list) {
2937 switch ($operator) {
2938 case PhabricatorQueryConstraint::OPERATOR_NOT:
2939 case PhabricatorQueryConstraint::OPERATOR_AND:
2940 case PhabricatorQueryConstraint::OPERATOR_OR:
2941 if (count($list) > 1) {
2942 return true;
2943 }
2944 break;
2945 case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
2946 // NOTE: We must always group query results rows when using an
2947 // "ANCESTOR" operator because a single task may be related to
2948 // two different descendants of a particular ancestor. For
2949 // discussion, see T12753.
2950 return true;
2951 case PhabricatorQueryConstraint::OPERATOR_NULL:
2952 case PhabricatorQueryConstraint::OPERATOR_ONLY:
2953 return true;
2954 }
2955 }
2956 }
2957
2958 return false;
2959 }
2960
2961
2962 /**
2963 * @task edgelogic
2964 */
2965 private function getEdgeLogicTableAlias($operator, $type) {
2966 return 'edgelogic_'.$operator.'_'.$type;
2967 }
2968
2969
2970 /**
2971 * @task edgelogic
2972 */
2973 private function buildEdgeLogicTableAliasCount($alias) {
2974 return $alias.'_count';
2975 }
2976
2977 /**
2978 * @task edgelogic
2979 */
2980 private function buildEdgeLogicTableAliasAncestor($alias) {
2981 return $alias.'_ancestor';
2982 }
2983
2984
2985 /**
2986 * Select certain edge logic constraint values.
2987 *
2988 * @task edgelogic
2989 */
2990 protected function getEdgeLogicValues(
2991 array $edge_types,
2992 array $operators) {
2993
2994 $values = array();
2995
2996 $constraint_lists = $this->edgeLogicConstraints;
2997 if ($edge_types) {
2998 $constraint_lists = array_select_keys($constraint_lists, $edge_types);
2999 }
3000
3001 foreach ($constraint_lists as $type => $constraints) {
3002 if ($operators) {
3003 $constraints = array_select_keys($constraints, $operators);
3004 }
3005 foreach ($constraints as $operator => $list) {
3006 foreach ($list as $constraint) {
3007 $value = (array)$constraint->getValue();
3008 foreach ($value as $v) {
3009 $values[] = $v;
3010 }
3011 }
3012 }
3013 }
3014
3015 return $values;
3016 }
3017
3018
3019 /**
3020 * Validate edge logic constraints for the query.
3021 *
3022 * @return $this
3023 * @task edgelogic
3024 */
3025 private function validateEdgeLogicConstraints() {
3026 if ($this->edgeLogicConstraintsAreValid) {
3027 return $this;
3028 }
3029
3030 foreach ($this->edgeLogicConstraints as $type => $constraints) {
3031 foreach ($constraints as $operator => $list) {
3032 switch ($operator) {
3033 case PhabricatorQueryConstraint::OPERATOR_EMPTY:
3034 throw new PhabricatorEmptyQueryException(
3035 pht('This query specifies an empty constraint.'));
3036 }
3037 }
3038 }
3039
3040 // This should probably be more modular, eventually, but we only do
3041 // project-based edge logic today.
3042
3043 $project_phids = $this->getEdgeLogicValues(
3044 array(
3045 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
3046 ),
3047 array(
3048 PhabricatorQueryConstraint::OPERATOR_AND,
3049 PhabricatorQueryConstraint::OPERATOR_OR,
3050 PhabricatorQueryConstraint::OPERATOR_NOT,
3051 PhabricatorQueryConstraint::OPERATOR_ANCESTOR,
3052 ));
3053 if ($project_phids) {
3054 $projects = id(new PhabricatorProjectQuery())
3055 ->setViewer($this->getViewer())
3056 ->setParentQuery($this)
3057 ->withPHIDs($project_phids)
3058 ->execute();
3059 $projects = mpull($projects, null, 'getPHID');
3060 foreach ($project_phids as $phid) {
3061 if (empty($projects[$phid])) {
3062 throw new PhabricatorEmptyQueryException(
3063 pht(
3064 'This query is constrained by a project you do not have '.
3065 'permission to see.'));
3066 }
3067 }
3068 }
3069
3070 $op_and = PhabricatorQueryConstraint::OPERATOR_AND;
3071 $op_or = PhabricatorQueryConstraint::OPERATOR_OR;
3072 $op_ancestor = PhabricatorQueryConstraint::OPERATOR_ANCESTOR;
3073
3074 foreach ($this->edgeLogicConstraints as $type => $constraints) {
3075 foreach ($constraints as $operator => $list) {
3076 switch ($operator) {
3077 case PhabricatorQueryConstraint::OPERATOR_ONLY:
3078 if (count($list) > 1) {
3079 throw new PhabricatorEmptyQueryException(
3080 pht(
3081 'This query specifies only() more than once.'));
3082 }
3083
3084 $have_and = idx($constraints, $op_and);
3085 $have_or = idx($constraints, $op_or);
3086 $have_ancestor = idx($constraints, $op_ancestor);
3087 if (!$have_and && !$have_or && !$have_ancestor) {
3088 throw new PhabricatorEmptyQueryException(
3089 pht(
3090 'This query specifies only(), but no other constraints '.
3091 'which it can apply to.'));
3092 }
3093 break;
3094 }
3095 }
3096 }
3097
3098 $this->edgeLogicConstraintsAreValid = true;
3099
3100 return $this;
3101 }
3102
3103
3104/* -( Spaces )------------------------------------------------------------- */
3105
3106
3107 /**
3108 * Constrain the query to return results from only specific Spaces.
3109 *
3110 * Pass a list of Space PHIDs, or `null` to represent the default space. Only
3111 * results in those Spaces will be returned.
3112 *
3113 * Queries are always constrained to include only results from spaces the
3114 * viewer has access to.
3115 *
3116 * @param list<string|null> $space_phids PHIDs of the spaces.
3117 * @task spaces
3118 */
3119 public function withSpacePHIDs(array $space_phids) {
3120 $object = $this->newResultObject();
3121
3122 if (!$object) {
3123 throw new Exception(
3124 pht(
3125 'This query (of class "%s") does not implement newResultObject(), '.
3126 'but must implement this method to enable support for Spaces.',
3127 get_class($this)));
3128 }
3129
3130 if (!($object instanceof PhabricatorSpacesInterface)) {
3131 throw new Exception(
3132 pht(
3133 'This query (of class "%s") returned an object of class "%s" from '.
3134 'getNewResultObject(), but it does not implement the required '.
3135 'interface ("%s"). Objects must implement this interface to enable '.
3136 'Spaces support.',
3137 get_class($this),
3138 get_class($object),
3139 'PhabricatorSpacesInterface'));
3140 }
3141
3142 $this->spacePHIDs = $space_phids;
3143
3144 return $this;
3145 }
3146
3147 public function withSpaceIsArchived($archived) {
3148 $this->spaceIsArchived = $archived;
3149 return $this;
3150 }
3151
3152
3153 /**
3154 * Constrain the query to include only results in valid Spaces.
3155 *
3156 * This method builds part of a WHERE clause which considers the spaces the
3157 * viewer has access to see with any explicit constraint on spaces added by
3158 * @{method:withSpacePHIDs}.
3159 *
3160 * @param AphrontDatabaseConnection $conn Database connection.
3161 * @return string|null Part of a WHERE clause, or null when there is no
3162 * object or the object is not an instance of PhabricatorSpacesInterface.
3163 * @task spaces
3164 */
3165 private function buildSpacesWhereClause(AphrontDatabaseConnection $conn) {
3166 $object = $this->newResultObject();
3167 if (!$object) {
3168 return null;
3169 }
3170
3171 if (!($object instanceof PhabricatorSpacesInterface)) {
3172 return null;
3173 }
3174
3175 $viewer = $this->getViewer();
3176
3177 // If we have an omnipotent viewer and no formal space constraints, don't
3178 // emit a clause. This primarily enables older migrations to run cleanly,
3179 // without fataling because they try to match a `spacePHID` column which
3180 // does not exist yet. See T8743, T8746.
3181 if ($viewer->isOmnipotent()) {
3182 if ($this->spaceIsArchived === null && $this->spacePHIDs === null) {
3183 return null;
3184 }
3185 }
3186
3187 // See T13240. If this query raises policy exceptions, don't filter objects
3188 // in the MySQL layer. We want them to reach the application layer so we
3189 // can reject them and raise an exception.
3190 if ($this->shouldRaisePolicyExceptions()) {
3191 return null;
3192 }
3193
3194 $space_phids = array();
3195 $include_null = false;
3196
3197 $all = PhabricatorSpacesNamespaceQuery::getAllSpaces();
3198 if (!$all) {
3199 // If there are no spaces at all, implicitly give the viewer access to
3200 // the default space.
3201 $include_null = true;
3202 } else {
3203 // Otherwise, give them access to the spaces they have permission to
3204 // see.
3205 $viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces(
3206 $viewer);
3207 foreach ($viewer_spaces as $viewer_space) {
3208 if ($this->spaceIsArchived !== null) {
3209 if ($viewer_space->getIsArchived() != $this->spaceIsArchived) {
3210 continue;
3211 }
3212 }
3213 $phid = $viewer_space->getPHID();
3214 $space_phids[$phid] = $phid;
3215 if ($viewer_space->getIsDefaultNamespace()) {
3216 $include_null = true;
3217 }
3218 }
3219 }
3220
3221 // If we have additional explicit constraints, evaluate them now.
3222 if ($this->spacePHIDs !== null) {
3223 $explicit = array();
3224 $explicit_null = false;
3225 foreach ($this->spacePHIDs as $phid) {
3226 if ($phid === null) {
3227 $space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();
3228 } else {
3229 $space = idx($all, $phid);
3230 }
3231
3232 if ($space) {
3233 $phid = $space->getPHID();
3234 $explicit[$phid] = $phid;
3235 if ($space->getIsDefaultNamespace()) {
3236 $explicit_null = true;
3237 }
3238 }
3239 }
3240
3241 // If the viewer can see the default space but it isn't on the explicit
3242 // list of spaces to query, don't match it.
3243 if ($include_null && !$explicit_null) {
3244 $include_null = false;
3245 }
3246
3247 // Include only the spaces common to the viewer and the constraints.
3248 $space_phids = array_intersect_key($space_phids, $explicit);
3249 }
3250
3251 if (!$space_phids && !$include_null) {
3252 if ($this->spacePHIDs === null) {
3253 throw new PhabricatorEmptyQueryException(
3254 pht('You do not have access to any spaces.'));
3255 } else {
3256 throw new PhabricatorEmptyQueryException(
3257 pht(
3258 'You do not have access to any of the spaces this query '.
3259 'is constrained to.'));
3260 }
3261 }
3262
3263 $alias = $this->getPrimaryTableAlias();
3264 if ($alias) {
3265 $col = qsprintf($conn, '%T.spacePHID', $alias);
3266 } else {
3267 $col = qsprintf($conn, 'spacePHID');
3268 }
3269
3270 if ($space_phids && $include_null) {
3271 return qsprintf(
3272 $conn,
3273 '(%Q IN (%Ls) OR %Q IS NULL)',
3274 $col,
3275 $space_phids,
3276 $col);
3277 } else if ($space_phids) {
3278 return qsprintf(
3279 $conn,
3280 '%Q IN (%Ls)',
3281 $col,
3282 $space_phids);
3283 } else {
3284 return qsprintf(
3285 $conn,
3286 '%Q IS NULL',
3287 $col);
3288 }
3289 }
3290
3291 private function hasFerretOrder() {
3292 $vector = $this->getOrderVector();
3293
3294 if ($vector->containsKey('rank')) {
3295 return true;
3296 }
3297
3298 if ($vector->containsKey('fulltext-created')) {
3299 return true;
3300 }
3301
3302 if ($vector->containsKey('fulltext-modified')) {
3303 return true;
3304 }
3305
3306 return false;
3307 }
3308
3309}