@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3/**
4 * 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}