@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
at recaptime-dev/main 3309 lines 94 kB view raw
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}