@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 1771 lines 48 kB view raw
1<?php 2 3/** 4 * Represents an abstract search engine for an application. It supports 5 * creating and storing saved queries. 6 * 7 * @task construct Constructing Engines 8 * @task app Applications 9 * @task builtin Builtin Queries 10 * @task uri Query URIs 11 * @task dates Date Filters 12 * @task order Result Ordering 13 * @task read Reading Utilities 14 * @task exec Paging and Executing Queries 15 * @task render Rendering Results 16 * @task custom Custom Fields 17 */ 18abstract class PhabricatorApplicationSearchEngine extends Phobject { 19 20 private $application; 21 private $viewer; 22 private $errors = array(); 23 private $request; 24 private $context; 25 private $controller; 26 private $namedQueries; 27 private $navigationItems = array(); 28 29 const CONTEXT_LIST = 'list'; 30 const CONTEXT_PANEL = 'panel'; 31 32 const BUCKET_NONE = 'none'; 33 34 public function setController(PhabricatorController $controller) { 35 $this->controller = $controller; 36 return $this; 37 } 38 39 /** 40 * @return PhabricatorController 41 */ 42 public function getController() { 43 return $this->controller; 44 } 45 46 public function buildResponse() { 47 $controller = $this->getController(); 48 $request = $controller->getRequest(); 49 50 $search = id(new PhabricatorApplicationSearchController()) 51 ->setQueryKey($request->getURIData('queryKey')) 52 ->setSearchEngine($this); 53 54 return $controller->delegateToController($search); 55 } 56 57 /** 58 * @return object|null Matching object (e.g. PhabricatorUser, PhamePost, or 59 * PhabricatorDashboardPanel), or null if no object matches. 60 */ 61 public function newResultObject() { 62 // We may be able to get this automatically if newQuery() is implemented. 63 $query = $this->newQuery(); 64 if ($query) { 65 $object = $query->newResultObject(); 66 if ($object) { 67 return $object; 68 } 69 } 70 71 return null; 72 } 73 74 public function newQuery() { 75 return null; 76 } 77 78 public function setViewer(PhabricatorUser $viewer) { 79 $this->viewer = $viewer; 80 return $this; 81 } 82 83 /** 84 * @return PhabricatorUser 85 * @throws PhutilInvalidStateException 86 */ 87 protected function requireViewer() { 88 if (!$this->viewer) { 89 throw new PhutilInvalidStateException('setViewer'); 90 } 91 return $this->viewer; 92 } 93 94 /** 95 * Set rendering context (e.g. list or panel) 96 * 97 * @param $context string A CONTEXT_* constant 98 */ 99 public function setContext($context) { 100 $this->context = $context; 101 return $this; 102 } 103 104 /** 105 * Whether this is in the context of rendering a panel 106 * 107 * @return bool True if in panel context 108 */ 109 public function isPanelContext() { 110 return ($this->context == self::CONTEXT_PANEL); 111 } 112 113 /** 114 * @param array<PHUIListItemView> $navigation_items 115 */ 116 public function setNavigationItems(array $navigation_items) { 117 assert_instances_of($navigation_items, PHUIListItemView::class); 118 $this->navigationItems = $navigation_items; 119 return $this; 120 } 121 122 /** 123 * @return array<PHUIListItemView> $navigation_items 124 */ 125 public function getNavigationItems() { 126 return $this->navigationItems; 127 } 128 129 public function canUseInPanelContext() { 130 return true; 131 } 132 133 public function saveQuery(PhabricatorSavedQuery $query) { 134 if ($query->getID()) { 135 throw new Exception( 136 pht( 137 'Query (with ID "%s") has already been saved. Queries are '. 138 'immutable once saved.', 139 $query->getID())); 140 } 141 142 $query->setEngineClassName(get_class($this)); 143 144 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 145 try { 146 $query->save(); 147 } catch (AphrontDuplicateKeyQueryException $ex) { 148 // Ignore, this is just a repeated search. 149 } 150 unset($unguarded); 151 } 152 153 /** 154 * Create a saved query object from the request. 155 * 156 * @param AphrontRequest $request The search request. 157 * @return PhabricatorSavedQuery 158 */ 159 public function buildSavedQueryFromRequest(AphrontRequest $request) { 160 $fields = $this->buildSearchFields(); 161 $viewer = $this->requireViewer(); 162 163 $saved = new PhabricatorSavedQuery(); 164 foreach ($fields as $field) { 165 $field->setViewer($viewer); 166 167 $value = $field->readValueFromRequest($request); 168 $saved->setParameter($field->getKey(), $value); 169 } 170 171 return $saved; 172 } 173 174 /** 175 * Executes the saved query. 176 * 177 * @param PhabricatorSavedQuery $original The saved query to operate on. 178 * @return PhabricatorQuery The result of the query. 179 */ 180 public function buildQueryFromSavedQuery(PhabricatorSavedQuery $original) { 181 $saved = clone $original; 182 $this->willUseSavedQuery($saved); 183 184 $fields = $this->buildSearchFields(); 185 $viewer = $this->requireViewer(); 186 187 $map = array(); 188 foreach ($fields as $field) { 189 $field->setViewer($viewer); 190 $field->readValueFromSavedQuery($saved); 191 $value = $field->getValueForQuery($field->getValue()); 192 $map[$field->getKey()] = $value; 193 } 194 195 $original->attachParameterMap($map); 196 $query = $this->buildQueryFromParameters($map); 197 198 $object = $this->newResultObject(); 199 if (!$object) { 200 return $query; 201 } 202 203 $extensions = $this->getEngineExtensions(); 204 foreach ($extensions as $extension) { 205 $extension->applyConstraintsToQuery($object, $query, $saved, $map); 206 } 207 208 $order = $saved->getParameter('order'); 209 $builtin = $query->getBuiltinOrderAliasMap(); 210 if (phutil_nonempty_string($order) && isset($builtin[$order])) { 211 $query->setOrder($order); 212 } else { 213 // If the order is invalid or not available, we choose the first 214 // builtin order. This isn't always the default order for the query, 215 // but is the first value in the "Order" dropdown, and makes the query 216 // behavior more consistent with the UI. In queries where the two 217 // orders differ, this order is the preferred order for humans. 218 $query->setOrder(head_key($builtin)); 219 } 220 221 return $query; 222 } 223 224 /** 225 * Hook for subclasses to adjust saved queries prior to use. 226 * 227 * If an application changes how queries are saved, it can implement this 228 * hook to keep old queries working the way users expect, by reading, 229 * adjusting, and overwriting parameters. 230 * 231 * @param PhabricatorSavedQuery $saved Saved query which will be executed. 232 * @return void 233 */ 234 protected function willUseSavedQuery(PhabricatorSavedQuery $saved) { 235 return; 236 } 237 238 protected function buildQueryFromParameters(array $parameters) { 239 throw new PhutilMethodNotImplementedException(); 240 } 241 242 /** 243 * Builds the search form using the request. 244 * 245 * @param AphrontFormView $form Form to populate. 246 * @param PhabricatorSavedQuery $saved Query from which to build the form. 247 * @return void 248 */ 249 public function buildSearchForm( 250 AphrontFormView $form, 251 PhabricatorSavedQuery $saved) { 252 253 $saved = clone $saved; 254 $this->willUseSavedQuery($saved); 255 256 $fields = $this->buildSearchFields(); 257 $fields = $this->adjustFieldsForDisplay($fields); 258 $viewer = $this->requireViewer(); 259 260 foreach ($fields as $field) { 261 $field->setViewer($viewer); 262 $field->readValueFromSavedQuery($saved); 263 } 264 265 foreach ($fields as $field) { 266 foreach ($field->getErrors() as $error) { 267 $this->addError(last($error)); 268 } 269 } 270 271 foreach ($fields as $field) { 272 $field->appendToForm($form); 273 } 274 } 275 276 protected function buildSearchFields() { 277 $fields = array(); 278 279 foreach ($this->buildCustomSearchFields() as $field) { 280 $fields[] = $field; 281 } 282 283 $object = $this->newResultObject(); 284 if ($object) { 285 $extensions = $this->getEngineExtensions(); 286 foreach ($extensions as $extension) { 287 $extension_fields = $extension->getSearchFields($object); 288 foreach ($extension_fields as $extension_field) { 289 $fields[] = $extension_field; 290 } 291 } 292 } 293 294 $query = $this->newQuery(); 295 if ($query && $this->shouldShowOrderField()) { 296 $orders = $query->getBuiltinOrders(); 297 $orders = ipull($orders, 'name'); 298 299 $fields[] = id(new PhabricatorSearchOrderField()) 300 ->setLabel(pht('Order By')) 301 ->setKey('order') 302 ->setOrderAliases($query->getBuiltinOrderAliasMap()) 303 ->setOptions($orders); 304 } 305 306 if (id(new PhabricatorAuditApplication())->isInstalled()) { 307 $buckets = $this->newResultBuckets(); 308 if ($query && $buckets) { 309 $bucket_options = array( 310 self::BUCKET_NONE => pht('No Bucketing'), 311 ) + mpull($buckets, 'getResultBucketName'); 312 313 $fields[] = id(new PhabricatorSearchSelectField()) 314 ->setLabel(pht('Bucket')) 315 ->setKey('bucket') 316 ->setOptions($bucket_options); 317 } 318 } 319 320 $field_map = array(); 321 foreach ($fields as $field) { 322 $key = $field->getKey(); 323 if (isset($field_map[$key])) { 324 throw new Exception( 325 pht( 326 'Two fields in this SearchEngine use the same key ("%s"), but '. 327 'each field must use a unique key.', 328 $key)); 329 } 330 $field_map[$key] = $field; 331 } 332 333 return $field_map; 334 } 335 336 protected function shouldShowOrderField() { 337 return true; 338 } 339 340 private function adjustFieldsForDisplay(array $field_map) { 341 $order = $this->getDefaultFieldOrder(); 342 343 $head_keys = array(); 344 $tail_keys = array(); 345 $seen_tail = false; 346 foreach ($order as $order_key) { 347 if ($order_key === '...') { 348 $seen_tail = true; 349 continue; 350 } 351 352 if (!$seen_tail) { 353 $head_keys[] = $order_key; 354 } else { 355 $tail_keys[] = $order_key; 356 } 357 } 358 359 $head = array_select_keys($field_map, $head_keys); 360 $body = array_diff_key($field_map, array_fuse($tail_keys)); 361 $tail = array_select_keys($field_map, $tail_keys); 362 363 $result = $head + $body + $tail; 364 365 // Force the fulltext "query" field to the top unconditionally. 366 $result = array_select_keys($result, array('query')) + $result; 367 368 foreach ($this->getHiddenFields() as $hidden_key) { 369 unset($result[$hidden_key]); 370 } 371 372 return $result; 373 } 374 375 protected function buildCustomSearchFields() { 376 throw new PhutilMethodNotImplementedException(); 377 } 378 379 380 /** 381 * Define the default display order for fields by returning a list of 382 * field keys. 383 * 384 * You can use the special key `...` to mean "all unspecified fields go 385 * here". This lets you easily put important fields at the top of the form, 386 * standard fields in the middle of the form, and less important fields at 387 * the bottom. 388 * 389 * For example, you might return a list like this: 390 * 391 * return array( 392 * 'authorPHIDs', 393 * 'reviewerPHIDs', 394 * '...', 395 * 'createdAfter', 396 * 'createdBefore', 397 * ); 398 * 399 * Any unspecified fields (including custom fields and fields added 400 * automatically by infrastructure) will be put in the middle. 401 * 402 * @return list<string> Default ordering for field keys. 403 */ 404 protected function getDefaultFieldOrder() { 405 return array(); 406 } 407 408 /** 409 * Return a list of field keys which should be hidden from the viewer. 410 * 411 * @return list<string> Fields to hide. 412 */ 413 protected function getHiddenFields() { 414 return array(); 415 } 416 417 /** 418 * @return array<string> 419 */ 420 public function getErrors() { 421 return $this->errors; 422 } 423 424 /** 425 * @param string $error 426 */ 427 public function addError($error) { 428 $this->errors[] = $error; 429 return $this; 430 } 431 432 /** 433 * Return an application URI corresponding to the results page of a query. 434 * Normally, this is something like `/application/query/QUERYKEY/`. 435 * 436 * @param string $query_key The query key to build a URI for. 437 * @return string URI where the query can be executed. 438 * @task uri 439 */ 440 public function getQueryResultsPageURI($query_key) { 441 return $this->getURI('query/'.$query_key.'/'); 442 } 443 444 445 /** 446 * Return an application URI for query management. This is used when, e.g., 447 * a query deletion operation is cancelled. 448 * 449 * @return string URI where queries can be managed. 450 * @task uri 451 */ 452 public function getQueryManagementURI() { 453 return $this->getURI('query/edit/'); 454 } 455 456 public function getQueryBaseURI() { 457 return $this->getURI(''); 458 } 459 460 public function getExportURI($query_key) { 461 return $this->getURI('query/'.$query_key.'/export/'); 462 } 463 464 public function getCustomizeURI($query_key, $object_phid, $context_phid) { 465 $params = array( 466 'search.objectPHID' => $object_phid, 467 'search.contextPHID' => $context_phid, 468 ); 469 470 $uri = $this->getURI('query/'.$query_key.'/customize/'); 471 $uri = new PhutilURI($uri, $params); 472 473 return phutil_string_cast($uri); 474 } 475 476 477 478 /** 479 * Return the URI to a path within the application. Used to construct default 480 * URIs for management and results. 481 * 482 * @return string URI to path; empty string if not implemented or applicable. 483 * @task uri 484 */ 485 abstract protected function getURI($path); 486 487 488 /** 489 * Return a human readable description of the type of objects this query 490 * searches for. 491 * 492 * For example, "Tasks" or "Commits". 493 * 494 * @return string Human-readable description of what this engine is used to 495 * find. 496 */ 497 abstract public function getResultTypeDescription(); 498 499 500 /** 501 * @return PhabricatorSavedQuery New instance of PhabricatorSavedQuery 502 */ 503 public function newSavedQuery() { 504 return id(new PhabricatorSavedQuery()) 505 ->setEngineClassName(get_class($this)); 506 } 507 508 public function addNavigationItems(PHUIListView $menu) { 509 $viewer = $this->requireViewer(); 510 511 $current_app = $this->getApplication()->getName(); 512 $search_app = id(new PhabricatorSearchApplication())->getName(); 513 514 if ($current_app === $search_app) { 515 $menu->newLabel(pht('Global Queries')); 516 } else { 517 $menu->newLabel(pht('%s Queries', $current_app)); 518 } 519 520 $named_queries = $this->loadEnabledNamedQueries(); 521 522 foreach ($named_queries as $query) { 523 $key = $query->getQueryKey(); 524 $uri = $this->getQueryResultsPageURI($key); 525 $menu->newLink($query->getQueryName(), $uri, 'query/'.$key); 526 } 527 528 if ($viewer->isLoggedIn()) { 529 $manage_uri = $this->getQueryManagementURI(); 530 $menu->newLink(pht('Edit Queries...'), $manage_uri, 'query/edit'); 531 } 532 533 $menu->newLabel(pht('Search')); 534 $advanced_uri = $this->getQueryResultsPageURI('advanced'); 535 if ($current_app === $search_app) { 536 $menu->newLink(pht('Global Search'), $advanced_uri, 'query/advanced'); 537 } else { 538 $menu->newLink( 539 pht('%s Search', $current_app), 540 $advanced_uri, 541 'query/advanced'); 542 } 543 544 foreach ($this->navigationItems as $extra_item) { 545 $menu->addMenuItem($extra_item); 546 } 547 548 return $this; 549 } 550 551 /** 552 * @return array<string,PhabricatorNamedQuery> Array with pairs of the query 553 * name (e.g. 'all' or 'open') and the corresponding PhabricatorNamedQuery 554 */ 555 public function loadAllNamedQueries() { 556 $viewer = $this->requireViewer(); 557 $builtin = $this->getBuiltinQueries(); 558 559 if ($this->namedQueries === null) { 560 $named_queries = id(new PhabricatorNamedQueryQuery()) 561 ->setViewer($viewer) 562 ->withEngineClassNames(array(get_class($this))) 563 ->withUserPHIDs( 564 array( 565 $viewer->getPHID(), 566 PhabricatorNamedQuery::SCOPE_GLOBAL, 567 )) 568 ->execute(); 569 $named_queries = mpull($named_queries, null, 'getQueryKey'); 570 571 $builtin = mpull($builtin, null, 'getQueryKey'); 572 573 foreach ($named_queries as $key => $named_query) { 574 if ($named_query->getIsBuiltin()) { 575 if (isset($builtin[$key])) { 576 $named_queries[$key]->setQueryName($builtin[$key]->getQueryName()); 577 unset($builtin[$key]); 578 } else { 579 unset($named_queries[$key]); 580 } 581 } 582 583 unset($builtin[$key]); 584 } 585 586 $named_queries = msortv($named_queries, 'getNamedQuerySortVector'); 587 $this->namedQueries = $named_queries; 588 } 589 590 return $this->namedQueries + $builtin; 591 } 592 593 /** 594 * @return array<string,PhabricatorNamedQuery> Array with pairs of the query 595 * name (e.g. 'all' or 'open') and the corresponding PhabricatorNamedQuery 596 */ 597 public function loadEnabledNamedQueries() { 598 $named_queries = $this->loadAllNamedQueries(); 599 foreach ($named_queries as $key => $named_query) { 600 if ($named_query->getIsBuiltin() && $named_query->getIsDisabled()) { 601 unset($named_queries[$key]); 602 } 603 } 604 return $named_queries; 605 } 606 607 /** 608 * @return string Name of the default query (e.g. 'all' or 'open') when not 609 * passing a query key, e.g. by going to generic /search/ or /maniphest/ 610 */ 611 public function getDefaultQueryKey() { 612 $viewer = $this->requireViewer(); 613 614 $configs = id(new PhabricatorNamedQueryConfigQuery()) 615 ->setViewer($viewer) 616 ->withEngineClassNames(array(get_class($this))) 617 ->withScopePHIDs( 618 array( 619 $viewer->getPHID(), 620 PhabricatorNamedQueryConfig::SCOPE_GLOBAL, 621 )) 622 ->execute(); 623 $configs = msortv($configs, 'getStrengthSortVector'); 624 625 $key_pinned = PhabricatorNamedQueryConfig::PROPERTY_PINNED; 626 $map = $this->loadEnabledNamedQueries(); 627 foreach ($configs as $config) { 628 $pinned = $config->getConfigProperty($key_pinned); 629 if (!isset($map[$pinned])) { 630 continue; 631 } 632 633 return $pinned; 634 } 635 636 return head_key($map); 637 } 638 639 protected function setQueryProjects( 640 PhabricatorCursorPagedPolicyAwareQuery $query, 641 PhabricatorSavedQuery $saved) { 642 643 $datasource = id(new PhabricatorProjectLogicalDatasource()) 644 ->setViewer($this->requireViewer()); 645 646 $projects = $saved->getParameter('projects', array()); 647 $constraints = $datasource->evaluateTokens($projects); 648 649 if ($constraints) { 650 $query->withEdgeLogicConstraints( 651 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, 652 $constraints); 653 } 654 655 return $this; 656 } 657 658 659/* -( Applications )------------------------------------------------------- */ 660 661 662 protected function getApplicationURI($path = '') { 663 return $this->getApplication()->getApplicationURI($path); 664 } 665 666 /** 667 * @return PhabricatorApplication A PhabricatorApplication subclass 668 */ 669 protected function getApplication() { 670 if (!$this->application) { 671 $class = $this->getApplicationClassName(); 672 673 $this->application = id(new PhabricatorApplicationQuery()) 674 ->setViewer($this->requireViewer()) 675 ->withClasses(array($class)) 676 ->withInstalled(true) 677 ->executeOne(); 678 679 if (!$this->application) { 680 throw new Exception( 681 pht( 682 'Application "%s" is not enabled!', 683 $class)); 684 } 685 } 686 687 return $this->application; 688 } 689 690 abstract public function getApplicationClassName(); 691 692 693/* -( Constructing Engines )----------------------------------------------- */ 694 695 696 /** 697 * Load all available application search engines. 698 * 699 * @return list<PhabricatorApplicationSearchEngine> All available engines. 700 * @task construct 701 */ 702 public static function getAllEngines() { 703 return id(new PhutilClassMapQuery()) 704 ->setAncestorClass(self::class) 705 ->execute(); 706 } 707 708 709 /** 710 * Get an engine by class name, if it exists. 711 * 712 * @return PhabricatorApplicationSearchEngine|null Engine, or null if it does 713 * not exist. 714 * @task construct 715 */ 716 public static function getEngineByClassName($class_name) { 717 return idx(self::getAllEngines(), $class_name); 718 } 719 720 721/* -( Builtin Queries )---------------------------------------------------- */ 722 723 724 /** 725 * @return array<string,PhabricatorNamedQuery> Array with pairs of the query 726 * name (e.g. 'all' or 'open') and the corresponding PhabricatorNamedQuery 727 * 728 * @task builtin 729 */ 730 public function getBuiltinQueries() { 731 $names = $this->getBuiltinQueryNames(); 732 733 $queries = array(); 734 $sequence = 0; 735 foreach ($names as $key => $name) { 736 $queries[$key] = id(new PhabricatorNamedQuery()) 737 ->setUserPHID(PhabricatorNamedQuery::SCOPE_GLOBAL) 738 ->setEngineClassName(get_class($this)) 739 ->setQueryName($name) 740 ->setQueryKey($key) 741 ->setSequence((1 << 24) + $sequence++) 742 ->setIsBuiltin(true); 743 } 744 745 return $queries; 746 } 747 748 749 /** 750 * @task builtin 751 */ 752 public function getBuiltinQuery($query_key) { 753 if (!$this->isBuiltinQuery($query_key)) { 754 throw new Exception(pht("'%s' is not a builtin!", $query_key)); 755 } 756 return idx($this->getBuiltinQueries(), $query_key); 757 } 758 759 760 /** 761 * @task builtin 762 */ 763 protected function getBuiltinQueryNames() { 764 return array(); 765 } 766 767 768 /** 769 * @task builtin 770 */ 771 public function isBuiltinQuery($query_key) { 772 $builtins = $this->getBuiltinQueries(); 773 return isset($builtins[$query_key]); 774 } 775 776 777 /** 778 * @task builtin 779 */ 780 public function buildSavedQueryFromBuiltin($query_key) { 781 throw new Exception(pht("Builtin '%s' is not supported!", $query_key)); 782 } 783 784 785/* -( Reading Utilities )--------------------------------------------------- */ 786 787 788 /** 789 * Read a list of user PHIDs from a request in a flexible way. This method 790 * supports either of these forms: 791 * 792 * users[]=alincoln&users[]=htaft 793 * users=alincoln,htaft 794 * 795 * Additionally, users can be specified either by PHID or by name. 796 * 797 * The main goal of this flexibility is to allow external programs to generate 798 * links to pages (like "alincoln's open revisions") without needing to make 799 * API calls. 800 * 801 * @param AphrontRequest $request Request to read user PHIDs from. 802 * @param string $key Key to read in the request. 803 * @param list<string> $allow_types (optional) Other permitted PHID type 804 * constants. 805 * @return list<string> List of user PHIDs and selector functions. 806 * @task read 807 */ 808 protected function readUsersFromRequest( 809 AphrontRequest $request, 810 $key, 811 array $allow_types = array()) { 812 813 $list = $this->readListFromRequest($request, $key); 814 815 $phids = array(); 816 $names = array(); 817 $allow_types = array_fuse($allow_types); 818 $user_type = PhabricatorPeopleUserPHIDType::TYPECONST; 819 foreach ($list as $item) { 820 $type = phid_get_type($item); 821 if ($type == $user_type) { 822 $phids[] = $item; 823 } else if (isset($allow_types[$type])) { 824 $phids[] = $item; 825 } else { 826 if (PhabricatorTypeaheadDatasource::isFunctionToken($item)) { 827 // If this is a function, pass it through unchanged; we'll evaluate 828 // it later. 829 $phids[] = $item; 830 } else { 831 $names[] = $item; 832 } 833 } 834 } 835 836 if ($names) { 837 $users = id(new PhabricatorPeopleQuery()) 838 ->setViewer($this->requireViewer()) 839 ->withUsernames($names) 840 ->execute(); 841 foreach ($users as $user) { 842 $phids[] = $user->getPHID(); 843 } 844 $phids = array_unique($phids); 845 } 846 847 return $phids; 848 } 849 850 851 /** 852 * Read a list of subscribers from a request in a flexible way. 853 * 854 * @param AphrontRequest $request Request to read PHIDs from. 855 * @param string $key Key to read in the request. 856 * @return list<string> List of object PHIDs. 857 * @task read 858 */ 859 protected function readSubscribersFromRequest( 860 AphrontRequest $request, 861 $key) { 862 return $this->readUsersFromRequest( 863 $request, 864 $key, 865 array( 866 PhabricatorProjectProjectPHIDType::TYPECONST, 867 )); 868 } 869 870 871 /** 872 * Read a list of generic PHIDs from a request in a flexible way. Like 873 * @{method:readUsersFromRequest}, this method supports either array or 874 * comma-delimited forms. Objects can be specified either by PHID or by 875 * object name. 876 * 877 * @param AphrontRequest $request Request to read PHIDs from. 878 * @param string $key Key to read in the request. 879 * @param list<string> $allow_types (optional) List of permitted PHID 880 * type constants. 881 * @return list<string> List of object PHIDs. 882 * 883 * @task read 884 */ 885 protected function readPHIDsFromRequest( 886 AphrontRequest $request, 887 $key, 888 array $allow_types = array()) { 889 890 $list = $this->readListFromRequest($request, $key); 891 892 $objects = id(new PhabricatorObjectQuery()) 893 ->setViewer($this->requireViewer()) 894 ->withNames($list) 895 ->execute(); 896 $list = mpull($objects, 'getPHID'); 897 898 if (!$list) { 899 return array(); 900 } 901 902 // If only certain PHID types are allowed, filter out all the others. 903 if ($allow_types) { 904 $allow_types = array_fuse($allow_types); 905 foreach ($list as $key => $phid) { 906 if (empty($allow_types[phid_get_type($phid)])) { 907 unset($list[$key]); 908 } 909 } 910 } 911 912 return $list; 913 } 914 915 916 /** 917 * Read a list of items from the request, in either array format or string 918 * format: 919 * 920 * list[]=item1&list[]=item2 921 * list=item1,item2 922 * 923 * This provides flexibility when constructing URIs, especially from external 924 * sources. 925 * 926 * @param AphrontRequest $request Request to read strings from. 927 * @param string $key Key to read in the request. 928 * @return list<string> List of values. 929 */ 930 protected function readListFromRequest( 931 AphrontRequest $request, 932 $key) { 933 $list = $request->getArr($key, null); 934 if ($list === null) { 935 $list = $request->getStrList($key); 936 } 937 938 if (!$list) { 939 return array(); 940 } 941 942 return $list; 943 } 944 945 protected function readBoolFromRequest( 946 AphrontRequest $request, 947 $key) { 948 if (!phutil_nonempty_string($request->getStr($key))) { 949 return null; 950 } 951 return $request->getBool($key); 952 } 953 954 955 protected function getBoolFromQuery(PhabricatorSavedQuery $query, $key) { 956 $value = $query->getParameter($key); 957 if ($value === null) { 958 return $value; 959 } 960 return $value ? 'true' : 'false'; 961 } 962 963 964/* -( Dates )-------------------------------------------------------------- */ 965 966 967 /** 968 * @task dates 969 */ 970 protected function parseDateTime($date_time) { 971 if (!strlen($date_time)) { 972 return null; 973 } 974 975 return PhabricatorTime::parseLocalTime($date_time, $this->requireViewer()); 976 } 977 978 979 /** 980 * @task dates 981 * @return void 982 */ 983 protected function buildDateRange( 984 AphrontFormView $form, 985 PhabricatorSavedQuery $saved_query, 986 $start_key, 987 $start_name, 988 $end_key, 989 $end_name) { 990 991 $start_str = $saved_query->getParameter($start_key); 992 $start = null; 993 if (strlen($start_str)) { 994 $start = $this->parseDateTime($start_str); 995 if (!$start) { 996 $this->addError( 997 pht( 998 '"%s" date can not be parsed.', 999 $start_name)); 1000 } 1001 } 1002 1003 1004 $end_str = $saved_query->getParameter($end_key); 1005 $end = null; 1006 if (strlen($end_str)) { 1007 $end = $this->parseDateTime($end_str); 1008 if (!$end) { 1009 $this->addError( 1010 pht( 1011 '"%s" date can not be parsed.', 1012 $end_name)); 1013 } 1014 } 1015 1016 if ($start && $end && ($start >= $end)) { 1017 $this->addError( 1018 pht( 1019 '"%s" must be a date before "%s".', 1020 $start_name, 1021 $end_name)); 1022 } 1023 1024 $form 1025 ->appendChild( 1026 id(new PHUIFormFreeformDateControl()) 1027 ->setName($start_key) 1028 ->setLabel($start_name) 1029 ->setValue($start_str)) 1030 ->appendChild( 1031 id(new AphrontFormTextControl()) 1032 ->setName($end_key) 1033 ->setLabel($end_name) 1034 ->setValue($end_str)); 1035 } 1036 1037 1038/* -( Paging and Executing Queries )--------------------------------------- */ 1039 1040 1041 protected function newResultBuckets() { 1042 return array(); 1043 } 1044 1045 public function getResultBucket(PhabricatorSavedQuery $saved) { 1046 $key = $saved->getParameter('bucket'); 1047 if ($key == self::BUCKET_NONE) { 1048 return null; 1049 } 1050 1051 $buckets = $this->newResultBuckets(); 1052 return idx($buckets, $key); 1053 } 1054 1055 /** 1056 * @return int|float Number of results to display (float if set to infinity) 1057 */ 1058 public function getPageSize(PhabricatorSavedQuery $saved) { 1059 $bucket = $this->getResultBucket($saved); 1060 1061 $limit = (int)$saved->getParameter('limit'); 1062 1063 if ($limit > 0) { 1064 if ($bucket) { 1065 $bucket->setPageSize($limit); 1066 } 1067 return $limit; 1068 } 1069 1070 if ($bucket) { 1071 return $bucket->getPageSize(); 1072 } 1073 1074 return 100; 1075 } 1076 1077 1078 public function shouldUseOffsetPaging() { 1079 return false; 1080 } 1081 1082 /** 1083 * @return AphrontCursorPagerView|PHUIPagerView 1084 */ 1085 public function newPagerForSavedQuery(PhabricatorSavedQuery $saved) { 1086 if ($this->shouldUseOffsetPaging()) { 1087 $pager = new PHUIPagerView(); 1088 } else { 1089 $pager = new AphrontCursorPagerView(); 1090 } 1091 1092 $page_size = $this->getPageSize($saved); 1093 if (is_finite($page_size)) { 1094 $pager->setPageSize($page_size); 1095 } else { 1096 // Consider an INF pagesize to mean a large finite pagesize. 1097 1098 // TODO: It would be nice to handle this more gracefully, but math 1099 // with INF seems to vary across PHP versions, systems, and runtimes. 1100 $pager->setPageSize(0xFFFF); 1101 } 1102 1103 return $pager; 1104 } 1105 1106 /** 1107 * @return array<string,PhabricatorObjectHandle> $results Array of pairs of 1108 * the object's PHID as key and the corresponding PhabricatorObjectHandle 1109 */ 1110 public function executeQuery( 1111 PhabricatorPolicyAwareQuery $query, 1112 AphrontView $pager) { 1113 1114 $query->setViewer($this->requireViewer()); 1115 1116 if ($this->shouldUseOffsetPaging()) { 1117 $objects = $query->executeWithOffsetPager($pager); 1118 } else { 1119 $objects = $query->executeWithCursorPager($pager); 1120 } 1121 1122 $this->didExecuteQuery($query); 1123 1124 return $objects; 1125 } 1126 1127 protected function didExecuteQuery(PhabricatorPolicyAwareQuery $query) { 1128 return; 1129 } 1130 1131 1132/* -( Rendering )---------------------------------------------------------- */ 1133 1134 1135 public function setRequest(AphrontRequest $request) { 1136 $this->request = $request; 1137 return $this; 1138 } 1139 1140 public function getRequest() { 1141 return $this->request; 1142 } 1143 1144 public function renderResults( 1145 array $objects, 1146 PhabricatorSavedQuery $query) { 1147 1148 $phids = $this->getRequiredHandlePHIDsForResultList($objects, $query); 1149 1150 if ($phids) { 1151 $handles = id(new PhabricatorHandleQuery()) 1152 ->setViewer($this->requireViewer()) 1153 ->withPHIDs($phids) 1154 ->execute(); 1155 } else { 1156 $handles = array(); 1157 } 1158 1159 return $this->renderResultList($objects, $query, $handles); 1160 } 1161 1162 protected function getRequiredHandlePHIDsForResultList( 1163 array $objects, 1164 PhabricatorSavedQuery $query) { 1165 return array(); 1166 } 1167 1168 abstract protected function renderResultList( 1169 array $objects, 1170 PhabricatorSavedQuery $query, 1171 array $handles); 1172 1173 1174/* -( Application Search )------------------------------------------------- */ 1175 1176 1177 public function getSearchFieldsForConduit() { 1178 $standard_fields = $this->buildSearchFields(); 1179 1180 $fields = array(); 1181 foreach ($standard_fields as $field_key => $field) { 1182 $conduit_key = $field->getConduitKey(); 1183 1184 if (isset($fields[$conduit_key])) { 1185 $other = $fields[$conduit_key]; 1186 $other_key = $other->getKey(); 1187 1188 throw new Exception( 1189 pht( 1190 'SearchFields "%s" (of class "%s") and "%s" (of class "%s") both '. 1191 'define the same Conduit key ("%s"). Keys must be unique.', 1192 $field_key, 1193 get_class($field), 1194 $other_key, 1195 get_class($other), 1196 $conduit_key)); 1197 } 1198 1199 $fields[$conduit_key] = $field; 1200 } 1201 1202 // These are handled separately for Conduit, so don't show them as 1203 // supported. 1204 unset($fields['order']); 1205 unset($fields['limit']); 1206 1207 $viewer = $this->requireViewer(); 1208 foreach ($fields as $key => $field) { 1209 $field->setViewer($viewer); 1210 } 1211 1212 return $fields; 1213 } 1214 1215 public function buildConduitResponse( 1216 ConduitAPIRequest $request, 1217 ConduitAPIMethod $method) { 1218 $viewer = $this->requireViewer(); 1219 1220 $query_key = $request->getValue('queryKey'); 1221 $is_empty_query_key = phutil_string_cast($query_key) === ''; 1222 if ($is_empty_query_key) { 1223 $saved_query = new PhabricatorSavedQuery(); 1224 } else if ($this->isBuiltinQuery($query_key)) { 1225 $saved_query = $this->buildSavedQueryFromBuiltin($query_key); 1226 } else if (strlen($query_key) !== PhabricatorHash::INDEX_DIGEST_LENGTH) { 1227 throw new Exception( 1228 pht( 1229 'Query key "%s" does not correspond to a valid query.', 1230 $query_key)); 1231 } else { 1232 $saved_query = id(new PhabricatorSavedQueryQuery()) 1233 ->setViewer($viewer) 1234 ->withQueryKeys(array($query_key)) 1235 ->executeOne(); 1236 if (!$saved_query) { 1237 throw new Exception( 1238 pht( 1239 'Query key "%s" does not correspond to a valid query.', 1240 $query_key)); 1241 } 1242 } 1243 1244 $constraints = $request->getValue('constraints', array()); 1245 if (!is_array($constraints)) { 1246 throw new Exception( 1247 pht( 1248 'Parameter "constraints" must be a map of constraints, got "%s".', 1249 phutil_describe_type($constraints))); 1250 } 1251 1252 $fields = $this->getSearchFieldsForConduit(); 1253 1254 foreach ($fields as $key => $field) { 1255 if (!$field->getConduitParameterType()) { 1256 unset($fields[$key]); 1257 } 1258 } 1259 1260 $valid_constraints = array(); 1261 foreach ($fields as $field) { 1262 foreach ($field->getValidConstraintKeys() as $key) { 1263 $valid_constraints[$key] = true; 1264 } 1265 } 1266 1267 foreach ($constraints as $key => $constraint) { 1268 if (empty($valid_constraints[$key])) { 1269 throw new Exception( 1270 pht( 1271 'Constraint "%s" is not a valid constraint for this query.', 1272 $key)); 1273 } 1274 } 1275 1276 foreach ($fields as $field) { 1277 if (!$field->getValueExistsInConduitRequest($constraints)) { 1278 continue; 1279 } 1280 1281 $value = $field->readValueFromConduitRequest( 1282 $constraints, 1283 $request->getIsStrictlyTyped()); 1284 $saved_query->setParameter($field->getKey(), $value); 1285 } 1286 1287 // NOTE: Currently, when running an ad-hoc query we never persist it into 1288 // a saved query. We might want to add an option to do this in the future 1289 // (for example, to enable a CLI-to-Web workflow where user can view more 1290 // details about results by following a link), but have no use cases for 1291 // it today. If we do identify a use case, we could save the query here. 1292 1293 $query = $this->buildQueryFromSavedQuery($saved_query); 1294 $pager = $this->newPagerForSavedQuery($saved_query); 1295 1296 $attachments = $this->getConduitSearchAttachments(); 1297 1298 $attachment_specs = $request->getValue('attachments', array()); 1299 if (!is_array($attachment_specs)) { 1300 throw new Exception( 1301 pht( 1302 'Parameter "attachments" must be a map of attachments, got "%s".', 1303 phutil_describe_type($attachment_specs))); 1304 } 1305 foreach ($attachment_specs as $key => $attachment) { 1306 if (empty($attachments[$key])) { 1307 throw new Exception( 1308 pht( 1309 'Attachment key "%s" is not a valid attachment for this query.', 1310 $key)); 1311 } 1312 } 1313 1314 $attachments = array_select_keys( 1315 $attachments, 1316 array_keys($attachment_specs)); 1317 1318 foreach ($attachments as $key => $attachment) { 1319 $attachment->setViewer($viewer); 1320 } 1321 1322 foreach ($attachments as $key => $attachment) { 1323 $attachment->willLoadAttachmentData($query, $attachment_specs[$key]); 1324 } 1325 1326 $this->setQueryOrderForConduit($query, $request); 1327 $this->setPagerLimitForConduit($pager, $request); 1328 $this->setPagerOffsetsForConduit($pager, $request); 1329 1330 $objects = $this->executeQuery($query, $pager); 1331 1332 $data = array(); 1333 if ($objects) { 1334 $field_extensions = $this->getConduitFieldExtensions(); 1335 1336 $extension_data = array(); 1337 foreach ($field_extensions as $key => $extension) { 1338 $extension_data[$key] = $extension->loadExtensionConduitData($objects); 1339 } 1340 1341 $attachment_data = array(); 1342 foreach ($attachments as $key => $attachment) { 1343 $attachment_data[$key] = $attachment->loadAttachmentData( 1344 $objects, 1345 $attachment_specs[$key]); 1346 } 1347 1348 foreach ($objects as $object) { 1349 $field_map = $this->getObjectWireFieldsForConduit( 1350 $object, 1351 $field_extensions, 1352 $extension_data); 1353 1354 $attachment_map = array(); 1355 foreach ($attachments as $key => $attachment) { 1356 $attachment_map[$key] = $attachment->getAttachmentForObject( 1357 $object, 1358 $attachment_data[$key], 1359 $attachment_specs[$key]); 1360 } 1361 1362 // If this is empty, we still want to emit a JSON object, not a 1363 // JSON list. 1364 if (!$attachment_map) { 1365 $attachment_map = (object)$attachment_map; 1366 } 1367 1368 $id = (int)$object->getID(); 1369 $phid = $object->getPHID(); 1370 1371 $data[] = array( 1372 'id' => $id, 1373 'type' => phid_get_type($phid), 1374 'phid' => $phid, 1375 'fields' => $field_map, 1376 'attachments' => $attachment_map, 1377 ); 1378 } 1379 } 1380 1381 return array( 1382 'data' => $data, 1383 'maps' => $method->getQueryMaps($query), 1384 'query' => array( 1385 // This may be `null` if we have not saved the query. 1386 'queryKey' => $saved_query->getQueryKey(), 1387 ), 1388 'cursor' => array( 1389 'limit' => $pager->getPageSize(), 1390 'after' => $pager->getNextPageID(), 1391 'before' => $pager->getPrevPageID(), 1392 'order' => $request->getValue('order'), 1393 ), 1394 ); 1395 } 1396 1397 public function getAllConduitFieldSpecifications() { 1398 $extensions = $this->getConduitFieldExtensions(); 1399 $object = $this->newQuery()->newResultObject(); 1400 1401 $map = array(); 1402 foreach ($extensions as $extension) { 1403 $specifications = $extension->getFieldSpecificationsForConduit($object); 1404 foreach ($specifications as $specification) { 1405 $key = $specification->getKey(); 1406 if (isset($map[$key])) { 1407 throw new Exception( 1408 pht( 1409 'Two field specifications share the same key ("%s"). Each '. 1410 'specification must have a unique key.', 1411 $key)); 1412 } 1413 $map[$key] = $specification; 1414 } 1415 } 1416 1417 return $map; 1418 } 1419 1420 private function getEngineExtensions() { 1421 $extensions = PhabricatorSearchEngineExtension::getAllEnabledExtensions(); 1422 1423 foreach ($extensions as $key => $extension) { 1424 $extension 1425 ->setViewer($this->requireViewer()) 1426 ->setSearchEngine($this); 1427 } 1428 1429 $object = $this->newResultObject(); 1430 foreach ($extensions as $key => $extension) { 1431 if (!$extension->supportsObject($object)) { 1432 unset($extensions[$key]); 1433 } 1434 } 1435 1436 return $extensions; 1437 } 1438 1439 1440 private function getConduitFieldExtensions() { 1441 $extensions = $this->getEngineExtensions(); 1442 $object = $this->newResultObject(); 1443 1444 foreach ($extensions as $key => $extension) { 1445 if (!$extension->getFieldSpecificationsForConduit($object)) { 1446 unset($extensions[$key]); 1447 } 1448 } 1449 1450 return $extensions; 1451 } 1452 1453 private function setQueryOrderForConduit($query, ConduitAPIRequest $request) { 1454 $order = $request->getValue('order'); 1455 if ($order === null) { 1456 return; 1457 } 1458 1459 if (is_scalar($order)) { 1460 $query->setOrder($order); 1461 } else { 1462 $query->setOrderVector($order); 1463 } 1464 } 1465 1466 private function setPagerLimitForConduit($pager, ConduitAPIRequest $request) { 1467 $limit = $request->getValue('limit'); 1468 1469 // If there's no limit specified and the query uses a weird huge page 1470 // size, just leave it at the default gigantic page size. Otherwise, 1471 // make sure it's between 1 and 100, inclusive. 1472 1473 if ($limit === null) { 1474 if ($pager->getPageSize() >= 0xFFFF) { 1475 return; 1476 } else { 1477 $limit = 100; 1478 } 1479 } 1480 1481 if ($limit > 100) { 1482 throw new Exception( 1483 pht( 1484 'Maximum page size for Conduit API method calls is 100, but '. 1485 'this call specified %s.', 1486 $limit)); 1487 } 1488 1489 if ($limit < 1) { 1490 throw new Exception( 1491 pht( 1492 'Minimum page size for API searches is 1, but this call '. 1493 'specified %s.', 1494 $limit)); 1495 } 1496 1497 $pager->setPageSize($limit); 1498 } 1499 1500 private function setPagerOffsetsForConduit( 1501 $pager, 1502 ConduitAPIRequest $request) { 1503 $before_id = $request->getValue('before'); 1504 if ($before_id !== null) { 1505 $pager->setBeforeID($before_id); 1506 } 1507 1508 $after_id = $request->getValue('after'); 1509 if ($after_id !== null) { 1510 $pager->setAfterID($after_id); 1511 } 1512 } 1513 1514 protected function getObjectWireFieldsForConduit( 1515 $object, 1516 array $field_extensions, 1517 array $extension_data) { 1518 1519 $fields = array(); 1520 foreach ($field_extensions as $key => $extension) { 1521 $data = idx($extension_data, $key, array()); 1522 $fields += $extension->getFieldValuesForConduit($object, $data); 1523 } 1524 1525 return $fields; 1526 } 1527 1528 public function getConduitSearchAttachments() { 1529 $extensions = $this->getEngineExtensions(); 1530 $object = $this->newResultObject(); 1531 1532 $attachments = array(); 1533 foreach ($extensions as $extension) { 1534 $extension_attachments = $extension->getSearchAttachments($object); 1535 foreach ($extension_attachments as $attachment) { 1536 $attachment_key = $attachment->getAttachmentKey(); 1537 if (isset($attachments[$attachment_key])) { 1538 $other = $attachments[$attachment_key]; 1539 throw new Exception( 1540 pht( 1541 'Two search engine attachments (of classes "%s" and "%s") '. 1542 'specify the same attachment key ("%s"); keys must be unique.', 1543 get_class($attachment), 1544 get_class($other), 1545 $attachment_key)); 1546 } 1547 $attachments[$attachment_key] = $attachment; 1548 } 1549 } 1550 1551 return $attachments; 1552 } 1553 1554 /** 1555 * Render a content body (if available) to onboard new users. 1556 * This body is usually visible when you have no elements in a list, 1557 * or when you force the rendering on a list with the `?nux=1` URL. 1558 * @return mixed|PhutilSafeHTML|null 1559 */ 1560 final public function renderNewUserView() { 1561 $body = $this->getNewUserBody(); 1562 1563 if (!$body) { 1564 return null; 1565 } 1566 1567 return $body; 1568 } 1569 1570 /** 1571 * Get a content body to onboard new users. 1572 * Traditionally this content is shown from an empty list, to explain 1573 * what a certain entity does, and how to create a new one. 1574 * @return mixed|PhutilSafeHTML|null 1575 */ 1576 protected function getNewUserHeader() { 1577 return null; 1578 } 1579 1580 protected function getNewUserBody() { 1581 return null; 1582 } 1583 1584 public function newUseResultsActions(PhabricatorSavedQuery $saved) { 1585 return array(); 1586 } 1587 1588 1589/* -( Export )------------------------------------------------------------- */ 1590 1591 1592 public function canExport() { 1593 $fields = $this->newExportFields(); 1594 return (bool)$fields; 1595 } 1596 1597 final public function newExportFieldList() { 1598 $object = $this->newResultObject(); 1599 1600 $builtin_fields = array( 1601 id(new PhabricatorIDExportField()) 1602 ->setKey('id') 1603 ->setLabel(pht('ID')), 1604 ); 1605 1606 if ($object->getConfigOption(LiskDAO::CONFIG_AUX_PHID)) { 1607 $builtin_fields[] = id(new PhabricatorPHIDExportField()) 1608 ->setKey('phid') 1609 ->setLabel(pht('PHID')); 1610 } 1611 1612 $fields = mpull($builtin_fields, null, 'getKey'); 1613 1614 $export_fields = $this->newExportFields(); 1615 foreach ($export_fields as $export_field) { 1616 $key = $export_field->getKey(); 1617 1618 if (isset($fields[$key])) { 1619 throw new Exception( 1620 pht( 1621 'Search engine ("%s") defines an export field with a key ("%s") '. 1622 'that collides with another field. Each field must have a '. 1623 'unique key.', 1624 get_class($this), 1625 $key)); 1626 } 1627 1628 $fields[$key] = $export_field; 1629 } 1630 1631 $extensions = $this->newExportExtensions(); 1632 foreach ($extensions as $extension) { 1633 $extension_fields = $extension->newExportFields(); 1634 foreach ($extension_fields as $extension_field) { 1635 $key = $extension_field->getKey(); 1636 1637 if (isset($fields[$key])) { 1638 throw new Exception( 1639 pht( 1640 'Export engine extension ("%s") defines an export field with '. 1641 'a key ("%s") that collides with another field. Each field '. 1642 'must have a unique key.', 1643 get_class($extension_field), 1644 $key)); 1645 } 1646 1647 $fields[$key] = $extension_field; 1648 } 1649 } 1650 1651 return $fields; 1652 } 1653 1654 final public function newExport(array $objects) { 1655 $object = $this->newResultObject(); 1656 $has_phid = $object->getConfigOption(LiskDAO::CONFIG_AUX_PHID); 1657 1658 $objects = array_values($objects); 1659 $n = count($objects); 1660 1661 $maps = array(); 1662 foreach ($objects as $object) { 1663 $map = array( 1664 'id' => $object->getID(), 1665 ); 1666 1667 if ($has_phid) { 1668 $map['phid'] = $object->getPHID(); 1669 } 1670 1671 $maps[] = $map; 1672 } 1673 1674 $export_data = $this->newExportData($objects); 1675 $export_data = array_values($export_data); 1676 if (count($export_data) !== count($objects)) { 1677 throw new Exception( 1678 pht( 1679 'Search engine ("%s") exported the wrong number of objects, '. 1680 'expected %s but got %s.', 1681 get_class($this), 1682 phutil_count($objects), 1683 phutil_count($export_data))); 1684 } 1685 1686 for ($ii = 0; $ii < $n; $ii++) { 1687 $maps[$ii] += $export_data[$ii]; 1688 } 1689 1690 $extensions = $this->newExportExtensions(); 1691 foreach ($extensions as $extension) { 1692 $extension_data = $extension->newExportData($objects); 1693 $extension_data = array_values($extension_data); 1694 if (count($export_data) !== count($objects)) { 1695 throw new Exception( 1696 pht( 1697 'Export engine extension ("%s") exported the wrong number of '. 1698 'objects, expected %s but got %s.', 1699 get_class($extension), 1700 phutil_count($objects), 1701 phutil_count($export_data))); 1702 } 1703 1704 for ($ii = 0; $ii < $n; $ii++) { 1705 $maps[$ii] += $extension_data[$ii]; 1706 } 1707 } 1708 1709 return $maps; 1710 } 1711 1712 protected function newExportFields() { 1713 return array(); 1714 } 1715 1716 protected function newExportData(array $objects) { 1717 throw new PhutilMethodNotImplementedException(); 1718 } 1719 1720 private function newExportExtensions() { 1721 $object = $this->newResultObject(); 1722 $viewer = $this->requireViewer(); 1723 1724 $extensions = PhabricatorExportEngineExtension::getAllExtensions(); 1725 1726 $supported = array(); 1727 foreach ($extensions as $extension) { 1728 $extension = clone $extension; 1729 $extension->setViewer($viewer); 1730 1731 if ($extension->supportsObject($object)) { 1732 $supported[] = $extension; 1733 } 1734 } 1735 1736 return $supported; 1737 } 1738 1739 /** 1740 * Load from object and from storage, and updates Custom Fields instances 1741 * that are attached to each object. 1742 * 1743 * @param array<PhabricatorCustomFieldInterface> $objects 1744 * @param string $role One of the PhabricatorCustomField::ROLE_ constants 1745 * @return array<string, PhabricatorCustomFieldList> Map of loaded fields 1746 * (PHID to PhabricatorCustomFieldList). 1747 * @task custom 1748 */ 1749 protected function loadCustomFields(array $objects, $role) { 1750 assert_instances_of($objects, PhabricatorCustomFieldInterface::class); 1751 1752 $query = new PhabricatorCustomFieldStorageQuery(); 1753 $lists = array(); 1754 1755 foreach ($objects as $object) { 1756 $field_list = PhabricatorCustomField::getObjectFields($object, $role); 1757 $field_list->readFieldsFromObject($object); 1758 foreach ($field_list->getFields() as $field) { 1759 // TODO move $viewer into PhabricatorCustomFieldStorageQuery 1760 $field->setViewer($this->viewer); 1761 } 1762 $lists[$object->getPHID()] = $field_list; 1763 $query->addFields($field_list->getFields()); 1764 } 1765 // This updates the field_list objects. 1766 $query->execute(); 1767 1768 return $lists; 1769 } 1770 1771}